Files
lightcraft/crates/engine/src/export.rs
T
Brandon ThomasandClaude Opus 5.5 03291d6f83 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 <noreply@anthropic.com>
2026-10-01 13:07:02 -04:00

854 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
#[default]
Jpeg,
Png,
Tiff,
Webp,
Avif,
}
impl ExportFormat {
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"jpeg" | "jpg" => Self::Jpeg,
"png" => Self::Png,
"tiff" | "tif" => Self::Tiff,
"webp" => Self::Webp,
"avif" => Self::Avif,
_ => return None,
})
}
pub fn extension(self) -> &'static str {
match self {
Self::Jpeg => "jpg",
Self::Png => "png",
Self::Tiff => "tif",
Self::Webp => "webp",
Self::Avif => "avif",
}
}
}
/// Output sharpening target (applied after resizing, in display-encoded values).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SharpenFor {
#[default]
None,
Screen,
Matte,
Glossy,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SharpenAmount {
Low,
#[default]
Standard,
High,
}
/// Where a watermark sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Anchor {
TopLeft,
Top,
TopRight,
Left,
Center,
Right,
BottomLeft,
Bottom,
#[default]
BottomRight,
}
/// A text watermark, sized relative to the image so every export size looks the same.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Watermark {
pub text: String,
/// Text height as a fraction of the image's short edge.
pub size: f32,
/// 0..1.
pub opacity: f32,
pub anchor: Anchor,
/// Margin as a fraction of the short edge.
pub inset: f32,
/// sRGB colour.
pub color: [u8; 3],
/// Soft dark drop shadow for legibility on bright areas.
pub shadow: bool,
}
impl Default for Watermark {
fn default() -> Self {
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
}
}
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// 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() || width == 0 || height == 0 {
return;
}
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
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.
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut prev = None;
for ch in text.chars() {
let id = sf.glyph_id(ch);
if let Some(p) = prev {
x += sf.kern(p, id);
}
glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent())));
x += sf.h_advance(id);
prev = Some(id);
}
let (tw, th) = (x, sf.ascent() - sf.descent());
let inset = wm.inset.clamp(0.0, 0.4) * short;
let (w, h) = (width as f32, height as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => inset,
Top | Center | Bottom => (w - tw) / 2.0,
TopRight | Right | BottomRight => w - inset - tw,
};
let oy = match wm.anchor {
TopLeft | Top | TopRight => inset,
Left | Center | Right => (h - th) / 2.0,
BottomLeft | Bottom | BottomRight => h - inset - th,
};
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 >= width as i32 || gy >= height as i32 {
return;
}
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)] };
for &(off, col, a) in passes {
for g in &glyphs {
if let Some(o) = font.outline_glyph(g.clone()) {
let b = o.px_bounds();
o.draw(|gx, gy, cov| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha));
}
}
}
}
/// Which metadata is embedded in exported files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MetadataPolicy {
/// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright.
#[default]
All,
/// Everything except camera/lens make, model and capture settings.
AllExceptCamera,
/// Copyright and creator only.
Copyright,
/// Nothing (the sRGB profile is still embedded).
None,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportOptions {
pub format: ExportFormat,
/// 1–100 (JPEG, AVIF).
pub quality: u8,
/// Resize so the long edge is at most this many pixels (`None` = full size).
pub long_edge: Option<u32>,
/// JPEG only: largest quality whose file fits in this many KB.
pub limit_kb: Option<u32>,
pub sharpen: SharpenFor,
pub sharpen_amount: SharpenAmount,
/// File name template: `{name}` (original stem), `{seq}` (1-based, zero-padded to 3), `{ext}` is appended.
pub naming: String,
pub metadata: MetadataPolicy,
/// Strip GPS / location even when the policy would include it.
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 {
fn default() -> Self {
Self {
format: ExportFormat::Jpeg,
quality: 90,
long_edge: None,
limit_kb: None,
sharpen: SharpenFor::None,
sharpen_amount: SharpenAmount::Standard,
naming: "{name}".into(),
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`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`).
pub fn from_json(p: &serde_json::Value) -> Self {
use serde_json::Value;
let d = Self::default();
let s = |k: &str| p.get(k).and_then(Value::as_str);
let u = |k: &str| p.get(k).and_then(Value::as_u64);
fn enm<T: serde::de::DeserializeOwned>(p: &Value, k: &str) -> Option<T> {
p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok())
}
Self {
format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format),
quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8),
long_edge: u("longEdge").filter(|v| *v > 0).map(|v| v.min(65_535) as u32),
limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32),
sharpen: enm(p, "sharpen").unwrap_or(d.sharpen),
sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount),
naming: s("naming").map_or(d.naming, str::to_string),
metadata: enm(p, "metadata").unwrap_or(d.metadata),
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
watermark: match p.get("watermark") {
Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }),
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
_ => 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() };
let name = base.replace("{name}", stem).replace("{seq}", &format!("{seq:03}"));
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
format!("{name}.{}", self.format.extension())
}
}
/// 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 None,
SharpenFor::Screen => (1usize, 0.35f32),
SharpenFor::Matte => (2, 0.6),
SharpenFor::Glossy => (1, 0.5),
};
let k = base
* match amount {
SharpenAmount::Low => 0.6,
SharpenAmount::Standard => 1.0,
SharpenAmount::High => 1.5,
};
if w < 3 || h < 3 {
return None;
}
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()];
for y in 0..h {
for x in 0..w {
let mut acc = [0.0f32; 3];
for d in -r..=r {
let (sx, sy) = if horizontal {
((x as isize + d).clamp(0, w as isize - 1) as usize, y)
} else {
(x, (y as isize + d).clamp(0, h as isize - 1) as usize)
};
let p = inp[sy * w + sx];
acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]];
}
let n = (2 * r + 1) as f32;
out[y * w + x] = acc.map(|v| v / n);
}
}
out
};
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
/// `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)
}
/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP.
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let mut img = img.clone();
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(named_space(space));
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
let e = EncodeImage::rgba8(&img);
let r = match o.format {
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta);
match o.limit_kb {
Some(kb) => {
let limit = kb as usize * 1024;
// Binary search for the highest quality that fits.
let (mut lo, mut hi) = (1u8, o.quality);
let mut best = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let b = jpeg(mid).map_err(|e| e.to_string())?;
if b.len() <= limit {
best = Some(b);
lo = mid + 1;
} else if mid == 1 {
break;
} else {
hi = mid - 1;
}
}
return best.ok_or_else(|| format!("cannot fit within {kb} KB"));
}
None => jpeg(o.quality),
}
}
ExportFormat::Png => encode::encode_png(&e, &meta),
ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta),
ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta),
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
};
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();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
None => s.parse().ok(),
}
.filter(|v: &f64| v.is_finite() && *v > 0.0)
}
/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing.
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
let m = &photo.meta;
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() };
match o.metadata {
MetadataPolicy::None => return None,
MetadataPolicy::Copyright => return Some(out),
MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {}
}
out.title = text(&m.title);
out.caption = text(&m.caption);
out.keywords = m.keywords.clone();
out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso);
out.rating = (photo.rating > 0).then_some(photo.rating as i8);
// Pixels are exported upright: orientation is baked in.
out.orientation = Some(lightcraft_meta::Orientation::Normal);
if !o.remove_location {
out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None });
}
if o.metadata == MetadataPolicy::All {
out.model = text(&m.camera);
out.lens_model = text(&m.lens);
out.focal_length = m.focal_mm.map(f64::from);
out.f_number = m.aperture.map(f64::from);
out.exposure_time = parse_shutter(&m.shutter);
out.iso = m.iso;
}
Some(out)
}
/// One exported file.
pub struct Exported {
pub file_name: String,
pub bytes: Vec<u8>,
pub width: usize,
pub height: usize,
}
/// Render photo `id` at the requested size (full size when `long_edge` is `None`) and encode it.
pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result<Exported, String> {
let p = session.catalog.photo(id).ok_or("no such photo")?;
let stem = p.file_name.rsplit_once('.').map_or(p.file_name.as_str(), |(a, _)| a).to_string();
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(), 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 })
}
#[cfg(test)]
mod tests {
use super::*;
fn test_image() -> Rgba8 {
let mut img = Rgba8::new(96, 64);
for (i, p) in img.data.iter_mut().enumerate() {
let (x, y) = (i % 96, i / 96);
*p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255];
}
img
}
#[test]
fn formats_have_magic() {
let img = test_image();
for (f, magic) in
[(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")]
{
let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap();
assert!(b.starts_with(magic), "{f:?}");
}
}
#[test]
fn size_limit_respected() {
let img = test_image();
let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap();
let kb = (full.len() / 1024 / 2).max(2) as u32;
let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap();
assert!(b.len() <= kb as usize * 1024);
}
#[test]
fn sharpen_increases_edge_contrast() {
let mut img = Rgba8::new(8, 8);
for (i, p) in img.data.iter_mut().enumerate() {
let v = if i % 8 < 4 { 80 } else { 170 };
*p = [v, v, v, 255];
}
output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High);
assert!(img.data[3][0] < 80 && img.data[4][0] > 170);
}
#[test]
fn metadata_policies() {
use lightcraft_catalog::{Photo, PhotoId, Source};
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
p.meta.camera = "Synthetic X2".into();
p.meta.copyright = "(c) Me".into();
p.meta.shutter = "1/250".into();
p.meta.gps = Some((43.0, -110.0));
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
assert_eq!(all.model.as_deref(), Some("Synthetic X2"));
assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9);
assert!(all.gps.is_some());
let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() };
let m = export_metadata(&p, &o).unwrap();
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
// embedded and readable back from the JPEG
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
let back = lightcraft_meta::extract(&jpg);
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
assert_eq!(back.copyright.as_deref(), Some("(c) Me"));
assert!(back.gps.is_some());
}
#[test]
fn watermark_draws_in_the_anchored_corner_only() {
let mut img = Rgba8::new(400, 300);
for p in img.data.iter_mut() {
*p = [0, 0, 0, 255];
}
let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}}));
let wm = o.watermark.clone().unwrap();
draw_watermark(&mut img, &wm);
let lit = |x0: usize, x1: usize, y0: usize, y1: usize| {
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count()
};
assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text");
assert!(lit(0, 200, 0, 150) == 0, "top-left untouched");
// string shorthand
assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me");
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
}
#[test]
fn naming_and_params() {
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
assert_eq!(o.format, ExportFormat::Jpeg);
assert_eq!(o.quality, 100);
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}"),
}
}
}