Merge slideshow-titles into slideshow-templates (origin/main: integration batch 26, #975; no conflicts)

This commit is contained in:
Jason Baldwin
2026-10-11 15:05:04 +02:00
51 changed files with 1935 additions and 128 deletions
+84 -1
View File
@@ -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 {
+5 -1
View File
@@ -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
}
}
};
+22 -2
View File
@@ -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);
}
}
+1 -1
View File
@@ -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];
+27
View File
@@ -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();
+65 -1
View File
@@ -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");