mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge main into worktree-agent-ab72a7623afb95196
# Conflicts: # crates/engine/src/import.rs # crates/engine/src/lib.rs # crates/ui-egui/src/headless.rs # crates/ui-egui/src/panels/dialogs.rs # crates/ui-egui/src/panels/grid.rs # docs/parity.md
This commit is contained in:
@@ -128,7 +128,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let fresh = DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() };
|
||||
// back to the photo's import defaults when a default preset gave it its look
|
||||
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -49,6 +49,7 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
|
||||
s.end_interaction()?;
|
||||
s.selection = Selection::single(vis[next]);
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"active": vis[next].0}))
|
||||
}
|
||||
|
||||
@@ -193,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
}
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
|
||||
|
||||
+125
-13
@@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Remove Spot",
|
||||
[],
|
||||
None,
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}",
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mode: SpotMode =
|
||||
@@ -265,31 +265,113 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("spot.add", "missing points"));
|
||||
}
|
||||
let d = Spot::default();
|
||||
let spot = Spot {
|
||||
let mut spot = Spot {
|
||||
mode,
|
||||
points: pts,
|
||||
size: f64_or(p, "size", d.size),
|
||||
feather: f64_or(p, "feather", d.feather),
|
||||
opacity: f64_or(p, "opacity", d.opacity),
|
||||
size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0),
|
||||
opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0),
|
||||
source_offset: point(p, "source"),
|
||||
};
|
||||
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if spot.source_offset.is_none() {
|
||||
// resolve the automatic source now: it gets a pin and stays put at every size
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
}
|
||||
dd.spots.push(spot);
|
||||
let n = dd.spots.len();
|
||||
s.set_develop(id, dd, "Remove")?;
|
||||
s.active_spot = Some(n - 1);
|
||||
Ok(json!({"index": n - 1}))
|
||||
}
|
||||
),
|
||||
cmd!("spot.delete", "Delete Spot", [], None, "{index}", has_active, |s, p| {
|
||||
let i = super::f64_req(p, "index", "spot.delete")? as usize;
|
||||
let id = s.active().ok_or_else(|| bad("spot.delete", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if i >= dd.spots.len() {
|
||||
return Err(bad("spot.delete", "no such spot"));
|
||||
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
|
||||
let n = spots_len(s);
|
||||
s.active_spot = match p.get("index").and_then(Value::as_u64) {
|
||||
Some(i) if (i as usize) < n => Some(i as usize),
|
||||
Some(_) => return Err(bad("spot.select", "no such spot")),
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({"activeSpot": s.active_spot}))
|
||||
}),
|
||||
cmd!(
|
||||
"spot.update",
|
||||
"Edit Spot",
|
||||
[],
|
||||
None,
|
||||
"{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.update";
|
||||
let i = spot_index(s, p, c)?;
|
||||
let mode: Option<SpotMode> = match p.get("mode") {
|
||||
Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?),
|
||||
None => None,
|
||||
};
|
||||
spots_edit(s, c, "Edit Spot", |spots| {
|
||||
let sp = &mut spots[i];
|
||||
if let Some(d) = point(p, "move") {
|
||||
sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y));
|
||||
// the source moves along (its offset is relative to the target)
|
||||
}
|
||||
if let Some(o) = point(p, "source") {
|
||||
sp.source_offset = Some(o);
|
||||
}
|
||||
if let Some(d) = point(p, "moveSource") {
|
||||
let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0));
|
||||
sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y));
|
||||
}
|
||||
if let Some(v) = p.get("size").and_then(Value::as_f64) {
|
||||
sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1);
|
||||
}
|
||||
if let Some(v) = p.get("feather").and_then(Value::as_f64) {
|
||||
sp.feather = v.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(v) = p.get("opacity").and_then(Value::as_f64) {
|
||||
sp.opacity = v.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
sp.mode = m;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
dd.spots.remove(i);
|
||||
s.set_develop(id, dd, "Delete Spot")?;
|
||||
),
|
||||
cmd!(
|
||||
"spot.refreshSource",
|
||||
"Refresh Source",
|
||||
[],
|
||||
None,
|
||||
"{index? (the selected spot)} — picks the next-best source away from the current one",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.refreshSource";
|
||||
let i = spot_index(s, p, c)?;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let spot = dd.spots[i].clone();
|
||||
let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?;
|
||||
spots_edit(s, c, "Refresh Source", |spots| {
|
||||
spots[i].source_offset = Some(next);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"index": i, "source": [next.x, next.y]}))
|
||||
}
|
||||
),
|
||||
cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| {
|
||||
let c = "spot.delete";
|
||||
let i = spot_index(s, p, c)?;
|
||||
spots_edit(s, c, "Delete Spot", |spots| {
|
||||
spots.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
s.active_spot = match s.active_spot {
|
||||
Some(a) if a == i => None,
|
||||
Some(a) if a > i => Some(a - 1),
|
||||
a => a,
|
||||
};
|
||||
ok()
|
||||
}),
|
||||
// ---- Red eye / pet eye
|
||||
@@ -358,6 +440,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Spot radius limits (fraction of the long edge).
|
||||
pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25);
|
||||
|
||||
fn spots_len(s: &Session) -> usize {
|
||||
s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `index` from the params, else the selected spot.
|
||||
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
|
||||
if i >= spots_len(s) {
|
||||
return Err(bad(c, "no such spot"));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spot>) -> Result<()>) -> Result<()> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
f(&mut d.spots)?;
|
||||
s.set_develop(id, d, label)
|
||||
}
|
||||
|
||||
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
|
||||
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let info = crate::media::source_info(s.catalog.photo(id)?);
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
|
||||
c(&mut a.exposure, -4.0, 4.0);
|
||||
|
||||
@@ -13,6 +13,7 @@ mod manage;
|
||||
mod masks;
|
||||
mod merge;
|
||||
mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
mod query;
|
||||
mod xmp;
|
||||
@@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(query::specs());
|
||||
v.extend(xmp::specs());
|
||||
v.extend(preset_files::specs());
|
||||
v.extend(prefs::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Library preferences (Settings → Import / Performance): develop defaults applied on import and
|
||||
//! the thumbnail cache budget. Saved in the library's `prefs.json` (see [`crate::library`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
use crate::import::{CameraDefault, ImportDefaults};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const ID: &str = "library.preferences";
|
||||
|
||||
fn current(s: &Session) -> Value {
|
||||
json!({
|
||||
"xmp": s.xmp,
|
||||
"import": s.import_defaults,
|
||||
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
|
||||
"persistent": s.library.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `"default"`, `""` or null = no preset; anything else must name an existing preset.
|
||||
fn preset_ref(s: &Session, v: &Value) -> Result<Option<String>> {
|
||||
match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(x) if x.is_empty() || x == "default" => Ok(None),
|
||||
Value::String(x) if s.presets.iter().any(|p| p.id == *x) => Ok(Some(x.clone())),
|
||||
Value::String(x) => Err(bad(ID, format!("unknown preset `{x}` (see preset.list)"))),
|
||||
other => Err(bad(ID, format!("preset must be a preset id or \"default\", not {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d: ImportDefaults = s.import_defaults.clone();
|
||||
let mut changed = false;
|
||||
if let Some(i) = p.get("import") {
|
||||
if let Some(v) = i.get("rawPreset") {
|
||||
d.raw_preset = preset_ref(s, v)?;
|
||||
}
|
||||
if let Some(v) = i.get("otherPreset") {
|
||||
d.other_preset = preset_ref(s, v)?;
|
||||
}
|
||||
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
|
||||
d.per_camera = v;
|
||||
}
|
||||
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
|
||||
d.cameras.clear();
|
||||
for c in a {
|
||||
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
||||
if camera.is_empty() {
|
||||
return Err(bad(ID, "each camera default needs `camera` (make + model)"));
|
||||
}
|
||||
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
|
||||
d.cameras.push(CameraDefault { camera, preset });
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
// set or clear one camera's default: {camera: {camera: "Make Model", preset: id|null}}
|
||||
if let Some(c) = p.get("camera") {
|
||||
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
||||
if camera.is_empty() {
|
||||
return Err(bad(ID, "`camera.camera` (make + model) is required"));
|
||||
}
|
||||
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
|
||||
d.cameras.retain(|x| !x.camera.eq_ignore_ascii_case(&camera));
|
||||
if c.get("remove").and_then(Value::as_bool) != Some(true) {
|
||||
d.cameras.push(CameraDefault { camera, preset });
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
s.import_defaults = d;
|
||||
if let Some(mb) = p.get("cacheMb") {
|
||||
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
|
||||
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
|
||||
} else if changed {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(current(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"library.preferences",
|
||||
"Library Preferences",
|
||||
[],
|
||||
None,
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
|
||||
always,
|
||||
prefs
|
||||
)]
|
||||
}
|
||||
@@ -89,6 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"selection": s.selection,
|
||||
"visibleCount": n,
|
||||
"activeMask": s.active_mask,
|
||||
"activeSpot": s.active_spot,
|
||||
"undo": s.undo.last().map(|u| u.label.clone()),
|
||||
"redo": s.redo.last().map(|u| u.label.clone()),
|
||||
"clipboard": s.clipboard.is_some(),
|
||||
|
||||
+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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,14 +95,74 @@ pub struct ImportReport {
|
||||
|
||||
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
|
||||
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
|
||||
/// camera intended).
|
||||
/// camera intended); a user default preset ([`ImportDefaults`]) goes on top.
|
||||
pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings {
|
||||
p.import_defaults()
|
||||
}
|
||||
|
||||
/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)?
|
||||
/// Does the photo still look as the camera rendered it (its embedded camera preview is then a
|
||||
/// fair stand-in)? Not when a default preset changed it on import.
|
||||
pub fn has_import_look(p: &Photo) -> bool {
|
||||
*p.develop == import_defaults(p)
|
||||
*p.develop == p.camera_defaults()
|
||||
}
|
||||
|
||||
/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`).
|
||||
/// Presets are referenced by id; a preset that no longer exists is ignored.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ImportDefaults {
|
||||
/// Preset applied to raw files (`None` = the LightCraft default).
|
||||
pub raw_preset: Option<String>,
|
||||
/// Use a camera's own default (below) when the photo's camera has one.
|
||||
pub per_camera: bool,
|
||||
/// Per-camera raw defaults, keyed by the camera's make + model as shown in Info
|
||||
/// (`"Canon EOS R5"`).
|
||||
pub cameras: Vec<CameraDefault>,
|
||||
/// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none).
|
||||
pub other_preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CameraDefault {
|
||||
/// Make + model (`Meta::camera`).
|
||||
pub camera: String,
|
||||
/// Preset id; `None` = the LightCraft default for this camera.
|
||||
pub preset: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportDefaults {
|
||||
/// The preset id that applies to a photo of this kind and camera, if any.
|
||||
pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> {
|
||||
if !raw {
|
||||
return self.other_preset.as_deref();
|
||||
}
|
||||
if self.per_camera
|
||||
&& let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera))
|
||||
{
|
||||
return c.preset.as_deref();
|
||||
}
|
||||
self.raw_preset.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Give a freshly imported photo its default settings: the camera defaults, then the matching
|
||||
/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look.
|
||||
pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
|
||||
let base = p.camera_defaults();
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
|
||||
match preset {
|
||||
Some(pr) => {
|
||||
let look = std::sync::Arc::new(pr.apply(&base, 1.0));
|
||||
p.develop = look.clone();
|
||||
p.import_look = (*look != base).then_some(look);
|
||||
}
|
||||
None => {
|
||||
p.develop = std::sync::Arc::new(base);
|
||||
p.import_look = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_supported(path: &Path) -> bool {
|
||||
@@ -351,7 +411,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.develop = std::sync::Arc::new(import_defaults(&p));
|
||||
apply_import_defaults(s, &mut p);
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
|
||||
@@ -97,6 +97,8 @@ pub struct Session {
|
||||
depth: u32,
|
||||
/// Selected mask (Masking panel), by mask id.
|
||||
pub active_mask: Option<u32>,
|
||||
/// Selected spot (Remove panel), by index into the active photo's spots.
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
pub library: Option<library::Library>,
|
||||
/// XMP sidecar preferences (persisted with the library).
|
||||
@@ -105,6 +107,10 @@ pub struct Session {
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// Develop defaults applied on import (persisted in prefs.json).
|
||||
pub import_defaults: import::ImportDefaults,
|
||||
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
|
||||
pub cache_mb: u32,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -137,10 +143,13 @@ impl Session {
|
||||
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
|
||||
depth: 0,
|
||||
active_mask: None,
|
||||
active_spot: None,
|
||||
library: None,
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
import_probes: Default::default(),
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,4 +394,8 @@ mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! LightCraft Library/
|
||||
//! catalog.snap catalog.log (lightcraft-catalog journal)
|
||||
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
|
||||
//! prefs.json (library preferences: XMP sidecars)
|
||||
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
|
||||
//! thumbs/ (rendered thumbnail cache, safe to delete)
|
||||
//! Originals/ (photos imported with "copy into library")
|
||||
//! ```
|
||||
@@ -99,11 +99,16 @@ impl Library {
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct PrefsFile {
|
||||
xmp: crate::sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (for Export with Previous).
|
||||
#[serde(alias = "last_export")]
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// Develop defaults for imported photos.
|
||||
import: crate::import::ImportDefaults,
|
||||
/// Thumbnail disk cache budget (MB, 0 = default).
|
||||
cache_mb: u32,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -165,6 +170,8 @@ impl Session {
|
||||
let prefs = read_json::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
@@ -180,8 +187,10 @@ impl Session {
|
||||
self.selection = Selection::single(*first);
|
||||
}
|
||||
let presets_written = presets_json(self);
|
||||
// photo ids are per library: drop decoded sources of the previous one
|
||||
self.media.clear_sources();
|
||||
if on_disk {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"));
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
@@ -262,11 +271,33 @@ impl Session {
|
||||
|
||||
/// Save the library preferences (no-op for in-memory sessions).
|
||||
pub fn save_prefs(&mut self) -> Result<()> {
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone() }).unwrap_or_default();
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
import: self.import_defaults.clone(),
|
||||
cache_mb: self.cache_mb,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
}
|
||||
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
|
||||
/// trims it to the new size. Saved with the library preferences.
|
||||
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
|
||||
self.cache_mb = mb;
|
||||
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
|
||||
let dir = lib.thumbs_dir();
|
||||
self.media.attach_disk_cache(&dir, self.cache_bytes());
|
||||
}
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
/// Compact the log into a snapshot now.
|
||||
pub fn compact_library(&mut self) -> Result<()> {
|
||||
self.persist()?;
|
||||
|
||||
@@ -143,8 +143,15 @@ fn source_bytes(img: &Rgb32f) -> usize {
|
||||
|
||||
impl MediaCache {
|
||||
/// Keep rendered thumbnails on disk in `dir` as well.
|
||||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path) {
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, DISK_CACHE_BYTES));
|
||||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
|
||||
}
|
||||
|
||||
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
|
||||
pub fn clear_sources(&mut self) {
|
||||
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
|
||||
self.previews.clear();
|
||||
self.full = None;
|
||||
}
|
||||
|
||||
/// Bytes the caches may hold together.
|
||||
@@ -355,7 +362,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 +441,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 +643,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();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Library preferences: import defaults (raw / other / per camera) applied by import, and their
|
||||
//! persistence in prefs.json.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::MediaKind;
|
||||
use lightcraft_develop::Preset;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-prefs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn user_preset(id: &str, settings: serde_json::Value) -> Preset {
|
||||
Preset { id: id.into(), name: id.into(), group: "User Presets".into(), settings, favorite: false, builtin: false }
|
||||
}
|
||||
|
||||
fn with_presets(s: &mut Session) {
|
||||
s.presets.push(user_preset("user.rawlook", json!({"light": {"exposure": 0.5}, "effects": {"clarity": 20.0}})));
|
||||
s.presets.push(user_preset("user.canonlook", json!({"light": {"contrast": 35.0}})));
|
||||
s.presets.push(user_preset("user.jpeglook", json!({"color": {"vibrance": 15.0}})));
|
||||
}
|
||||
|
||||
fn dng(dir: &Path, name: &str, make: &str, model: &str) {
|
||||
let meta = lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
|
||||
std::fs::write(dir.join(name), crate::tests_xmp::synthetic_dng_with(None, meta)).unwrap();
|
||||
}
|
||||
|
||||
fn png(path: &Path) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i * 7) as u8, (i * 3) as u8, 90, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn photo_named<'a>(s: &'a Session, name: &str) -> &'a lightcraft_catalog::Photo {
|
||||
s.catalog.photos().find(|p| p.file_name == name).unwrap_or_else(|| panic!("{name} not imported"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_and_other_defaults_are_applied_on_import() {
|
||||
let src = temp_dir("apply");
|
||||
dng(&src, "nikon.dng", "Nikon", "Z 9");
|
||||
dng(&src, "canon.dng", "Canon", "EOS R5");
|
||||
png(&src.join("plain.png"));
|
||||
let mut s = Session::new().with_fs();
|
||||
with_presets(&mut s);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.preferences",
|
||||
&json!({"import": {"rawPreset": "user.rawlook", "otherPreset": "user.jpeglook", "perCamera": true,
|
||||
"cameras": [{"camera": "Canon EOS R5", "preset": "user.canonlook"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["import"]["rawPreset"], "user.rawlook", "{r}");
|
||||
assert!(s.execute("library.preferences", &json!({"import": {"rawPreset": "nope"}})).is_err(), "unknown presets are rejected");
|
||||
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 3);
|
||||
let nikon = photo_named(&s, "nikon.dng");
|
||||
assert_eq!(nikon.kind, MediaKind::Raw);
|
||||
assert_eq!(nikon.meta.camera, "Nikon Z 9");
|
||||
assert_eq!((nikon.develop.light.exposure, nikon.develop.effects.clarity), (0.5, 20.0), "global raw default");
|
||||
assert_eq!(nikon.develop.detail.sharpen_amount, 40.0, "raw camera defaults stay underneath the preset");
|
||||
assert!(!nikon.is_edited(), "the default look counts as unedited");
|
||||
assert!(!crate::import::has_import_look(nikon), "a preset look isn't what the embedded preview shows");
|
||||
let canon = photo_named(&s, "canon.dng");
|
||||
assert_eq!((canon.develop.light.contrast, canon.develop.light.exposure), (35.0, 0.0), "per-camera default wins");
|
||||
let plain = photo_named(&s, "plain.png");
|
||||
assert_eq!((plain.develop.color.vibrance, plain.develop.light.exposure), (15.0, 0.0), "non-raw default");
|
||||
|
||||
// editing then Reset returns to the default look
|
||||
let id = nikon.id;
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().is_edited());
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.develop.light.exposure, 0.5);
|
||||
assert!(!p.is_edited());
|
||||
|
||||
// per-camera off: the Canon gets the global raw default; clearing it gives the plain defaults
|
||||
let mut s2 = Session::new().with_fs();
|
||||
with_presets(&mut s2);
|
||||
s2.execute("library.preferences", &json!({"camera": {"camera": "canon eos r5", "preset": "user.canonlook"}})).unwrap();
|
||||
s2.execute("library.import", &json!({"paths": [src.join("canon.dng").to_string_lossy()]})).unwrap();
|
||||
let c = s2.catalog.photos().next().unwrap();
|
||||
assert_eq!(c.develop.light.contrast, 0.0, "per-camera defaults are off unless enabled");
|
||||
assert!(c.import_look.is_none());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_persist_with_the_library() {
|
||||
let lib = temp_dir("lib");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
with_presets(&mut s);
|
||||
s.persist().unwrap(); // presets.json
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute(
|
||||
"library.preferences",
|
||||
&json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}),
|
||||
)
|
||||
.unwrap();
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.xmp.auto_write);
|
||||
assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook"));
|
||||
assert!(s.import_defaults.per_camera);
|
||||
assert_eq!(s.import_defaults.cameras.len(), 1);
|
||||
assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook"));
|
||||
assert_eq!(s.cache_mb, 512);
|
||||
assert_eq!(s.cache_bytes(), 512 << 20);
|
||||
let r = s.execute("library.preferences", &json!({})).unwrap();
|
||||
assert_eq!(r["cacheMb"], 512);
|
||||
assert_eq!(r["persistent"], true);
|
||||
// older prefs.json files (snake_case `last_export`, no import section) still load
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_export, Some(json!({"format": "png"})));
|
||||
assert_eq!(s.import_defaults, Default::default());
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Remove tool commands: add (automatic source), select, move target/source, edit, refresh, delete.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
|
||||
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spots_select_move_edit_refresh_and_delete() {
|
||||
let mut s = Session::with_demo();
|
||||
// a remove spot gets a resolved source (a pin to show) and is selected
|
||||
let r = s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.02})).unwrap();
|
||||
assert_eq!(r["index"], 0);
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
let src0 = spots(&s)[0].source_offset.expect("automatic source resolved");
|
||||
assert!(src0.x.hypot(src0.y) > 0.02, "the source is outside the spot: {src0:?}");
|
||||
s.execute("spot.add", &json!({"mode": "clone", "points": [[0.7, 0.3]], "source": [0.1, 0.0]})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(1));
|
||||
assert_eq!(s.execute("library.state", &json!({})).unwrap()["activeSpot"], 1);
|
||||
// select
|
||||
s.execute("spot.select", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
assert!(s.execute("spot.select", &json!({"index": 9})).is_err());
|
||||
// move the target (the source follows: its offset is relative), then the source alone
|
||||
s.execute("spot.update", &json!({"move": [0.05, -0.1]})).unwrap();
|
||||
let sp = &spots(&s)[0];
|
||||
assert!((sp.points[0].x - 0.45).abs() < 1e-9 && (sp.points[0].y - 0.4).abs() < 1e-9);
|
||||
assert_eq!(sp.source_offset, Some(src0));
|
||||
s.execute("spot.update", &json!({"moveSource": [0.01, 0.02]})).unwrap();
|
||||
let o = spots(&s)[0].source_offset.unwrap();
|
||||
assert!((o.x - src0.x - 0.01).abs() < 1e-9 && (o.y - src0.y - 0.02).abs() < 1e-9);
|
||||
// size / feather / opacity of the selected spot (clamped), one undo step each
|
||||
s.execute("spot.update", &json!({"size": 0.03, "feather": 20, "opacity": 150})).unwrap();
|
||||
let sp = &spots(&s)[0];
|
||||
assert_eq!((sp.size, sp.feather, sp.opacity), (0.03, 20.0, 100.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spots(&s)[0].size, 0.02);
|
||||
// refresh: a different source, at least a spot radius away
|
||||
let before = spots(&s)[0].source_offset.unwrap();
|
||||
let r = s.execute("spot.refreshSource", &json!({})).unwrap();
|
||||
let after = spots(&s)[0].source_offset.unwrap();
|
||||
assert!((after.x - before.x).hypot(after.y - before.y) > 0.01, "{r}");
|
||||
// delete the selected spot: the selection clears; deleting before a selected one shifts it
|
||||
s.execute("spot.delete", &json!({})).unwrap();
|
||||
assert_eq!((spots(&s).len(), s.active_spot), (1, None));
|
||||
assert_eq!(spots(&s)[0].mode, lightcraft_develop::SpotMode::Clone);
|
||||
assert!(s.execute("spot.delete", &json!({})).is_err(), "nothing selected");
|
||||
s.execute("spot.add", &json!({"points": [[0.2, 0.2]], "source": [0.05, 0.0]})).unwrap();
|
||||
s.execute("spot.delete", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
// another photo: no selection
|
||||
s.execute("library.next", &json!({})).unwrap();
|
||||
assert_eq!(s.active_spot, None);
|
||||
}
|
||||
@@ -174,6 +174,11 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
||||
}
|
||||
|
||||
fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
||||
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
|
||||
}
|
||||
|
||||
/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata).
|
||||
pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
|
||||
use lightcraft_raw::*;
|
||||
let (w, h) = (32usize, 24usize);
|
||||
let cfa = Cfa::bayer("RGGB").unwrap();
|
||||
@@ -203,9 +208,9 @@ fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata::default(),
|
||||
metadata,
|
||||
};
|
||||
write_dng(&raw, &DngWriteOptions { xmp: Some(xmp.to_string()), ..Default::default() }).unwrap()
|
||||
write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user