mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
Merge slideshow-titles into slideshow-templates (origin/main: integration batch 26, #975; no conflicts)
This commit is contained in:
@@ -833,6 +833,22 @@ fn to_prophoto() -> Mat3 {
|
||||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||||
}
|
||||
|
||||
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
|
||||
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
|
||||
/// its colour; in between the two are blended, so noisy shadows show no step.
|
||||
const MIN_LUMINANCE_KEPT: f32 = 0.25;
|
||||
const NO_LUMINANCE_KEPT: f32 = 0.15;
|
||||
|
||||
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
|
||||
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
|
||||
fn restore_weight(share: f32) -> f32 {
|
||||
if !share.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||||
pub(crate) struct HueSat<'a> {
|
||||
@@ -852,7 +868,19 @@ impl<'a> HueSat<'a> {
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
|
||||
// restoring the luminance divides by the table's: near black, where noise makes some channels
|
||||
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
|
||||
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
|
||||
return rgb;
|
||||
}
|
||||
let w = restore_weight(after / before);
|
||||
if w >= 1.0 {
|
||||
return out.map(|v| v * before / after);
|
||||
}
|
||||
if w <= 0.0 {
|
||||
return rgb;
|
||||
}
|
||||
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,6 +1467,61 @@ mod tests {
|
||||
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_never_turns_near_black_noise_white() {
|
||||
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
|
||||
};
|
||||
let mut worst = 0.0f32;
|
||||
for _ in 0..200_000 {
|
||||
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
|
||||
if luminance_2020(rgb) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let out = hue_sat.apply(rgb);
|
||||
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
|
||||
}
|
||||
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
|
||||
assert_eq!(restore_weight(0.0), 0.0);
|
||||
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
|
||||
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
|
||||
assert_eq!(restore_weight(1.0), 1.0);
|
||||
assert_eq!(restore_weight(f32::NAN), 0.0);
|
||||
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
|
||||
let mut last = 0.0f32;
|
||||
for i in 0..=400 {
|
||||
let w = restore_weight(i as f32 * 0.001);
|
||||
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
|
||||
last = w;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
|
||||
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
|
||||
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
assert!(after >= MIN_LUMINANCE_KEPT * before);
|
||||
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
|
||||
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
|
||||
RawImage {
|
||||
|
||||
@@ -239,10 +239,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => {
|
||||
// the photo's built-in defaults (raws: capture sharpening and colour noise
|
||||
// reduction, embedded lens corrections), as at import, with the white balance
|
||||
// of its current as-shot reference
|
||||
let info = s.source_info(id);
|
||||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
..defaults
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
|
||||
// an out-of-range offset to zero, returning the first page instead of no photos.
|
||||
fn pagination_usize(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
|
||||
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
s.visible_cloned()
|
||||
};
|
||||
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
|
||||
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
|
||||
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
|
||||
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
|
||||
Ok(json!({"total": ids.len(), "photos": items}))
|
||||
}),
|
||||
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pagination_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
|
||||
assert_eq!(pagination_usize(0), 0);
|
||||
assert_eq!(pagination_usize(200), 200);
|
||||
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 42;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 50;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
|
||||
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
|
||||
/// keeps none.
|
||||
#[test]
|
||||
fn reset_edits_restores_a_raws_import_defaults() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
let mut s = demo();
|
||||
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
|
||||
let name = format!("p{id}.{format}");
|
||||
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
|
||||
if raw {
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
}
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
|
||||
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -32,7 +32,9 @@ impl Scratch {
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
// Fixtures use `/`; rebuild components so expected paths match the native separators
|
||||
// returned by directory scans, including for files that have already been removed.
|
||||
self.0.join(rel).components().collect::<PathBuf>().to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +105,68 @@ fn an_unchanged_folder_has_no_changes() {
|
||||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() {
|
||||
for forward_import in [true, false] {
|
||||
for import_folder in [true, false] {
|
||||
let dir = Scratch::new(&format!("separators-{forward_import}-{import_folder}"));
|
||||
let folder = dir.path("trip");
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/day1/b.png");
|
||||
write_png(&a, 1);
|
||||
write_png(&b, 2);
|
||||
let spelling = |path: &str, forward: bool| if forward { path.replace('\\', "/") } else { path.to_string() };
|
||||
let import_path = spelling(&folder, forward_import);
|
||||
let sync_path = spelling(&folder, !forward_import);
|
||||
assert_ne!(import_path, sync_path, "exercise distinct Windows path spellings");
|
||||
let inputs = if import_folder { vec![import_path.clone()] } else { vec![spelling(&a, forward_import), spelling(&b, forward_import)] };
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": inputs})).unwrap();
|
||||
assert_eq!(imported["imported"].as_array().unwrap().len(), 2, "{imported}");
|
||||
let before = s.execute("catalog.query", &json!({})).unwrap();
|
||||
let photos = before["photos"].as_array().unwrap();
|
||||
let a_id = photos.iter().find(|p| p["fileName"] == "a.png").unwrap()["id"].clone();
|
||||
let b_id = photos.iter().find(|p| p["fileName"] == "b.png").unwrap()["id"].clone();
|
||||
|
||||
let duplicate = s.execute("library.import", &json!({"paths": [sync_path]})).unwrap();
|
||||
assert!(duplicate["imported"].as_array().unwrap().is_empty(), "{duplicate}");
|
||||
assert_eq!(duplicate["duplicates"].as_array().unwrap().len(), 2, "{duplicate}");
|
||||
assert_eq!(s.execute("catalog.query", &json!({})).unwrap(), before, "reimport preserves the photos and their ids");
|
||||
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
let c = dir.path("trip/day2/c.png");
|
||||
write_png(&c, 3);
|
||||
let changes = scan(&mut s, &sync_path);
|
||||
assert_eq!(changes["new"].as_array().unwrap().len(), 1, "{changes}");
|
||||
assert_eq!(Path::new(changes["new"][0]["path"].as_str().unwrap()), Path::new(&c));
|
||||
assert_eq!(changes["missing"].as_array().unwrap().len(), 1, "{changes}");
|
||||
assert_eq!(changes["missing"][0]["id"], b_id, "the missing photo keeps its id");
|
||||
assert!(changes["moved"].as_array().unwrap().is_empty(), "{changes}");
|
||||
|
||||
// Switching spelling again must consume the confirmed scan of this same folder.
|
||||
let synced = s.execute("folder.synchronize", &json!({"path": import_path, "scanned": true, "removeMissing": true})).unwrap();
|
||||
assert_eq!(
|
||||
(synced["imported"].as_u64(), synced["removed"].as_u64(), synced["relinked"].as_u64()),
|
||||
(Some(1), Some(1), Some(0)),
|
||||
"{synced}"
|
||||
);
|
||||
assert!(synced["failed"].as_array().unwrap().is_empty(), "{synced}");
|
||||
let after = s.execute("catalog.query", &json!({})).unwrap();
|
||||
let photos = after["photos"].as_array().unwrap();
|
||||
assert_eq!(photos.len(), 2, "{after}");
|
||||
assert!(photos.iter().any(|p| p["id"] == a_id && p["fileName"] == "a.png"), "{after}");
|
||||
assert!(photos.iter().any(|p| p["fileName"] == "c.png" && p["id"] != a_id && p["id"] != b_id), "{after}");
|
||||
for path in [&import_path, &sync_path] {
|
||||
let changes = scan(&mut s, path);
|
||||
for kind in ["new", "missing", "moved", "metadata"] {
|
||||
assert!(changes[kind].as_array().unwrap().is_empty(), "{path}: {changes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||||
let dir = Scratch::new("new");
|
||||
|
||||
Reference in New Issue
Block a user