mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
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>
854 lines
38 KiB
Rust
854 lines
38 KiB
Rust
//! 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}"),
|
||
}
|
||
}
|
||
}
|