Merge pull request #3111 from dylanmtaylor/sonar/enforce-clippy-standards

Enforce Rust coding standards via Clippy
This commit is contained in:
Dylan M. Taylor
2026-10-28 01:31:16 -04:00
committed by GitHub
172 changed files with 6703 additions and 4880 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic in tests only; indexing is not denied. Every production crate
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
integration tests, examples and benches. New crates start with the attribute.
+7 -4
View File
@@ -52,10 +52,13 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
integration tests, examples and benches. New crates start with the attribute.
- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic allowances scoped to tests; indexing is not denied. The workspace
denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with
`[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root
(`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include
`[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots include
the attribute before modules or other crate items; crate-level `//!` documentation may precede it.
## Non-negotiables
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
+3 -1
View File
@@ -122,9 +122,11 @@ web-sys = "0.3"
unsafe_code = "deny"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
needless_range_loop = "allow"
type_complexity = "allow"
excessive_precision = "allow"
+1 -1
View File
@@ -1,5 +1,5 @@
use super::prefs::config_dir;
use super::*;
use super::window_gpu;
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{LibraryProblem, LightcraftApp, UiState};
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
+1
View File
@@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+1 -1
View File
@@ -14,7 +14,7 @@
use std::sync::Arc;
use std::time::Instant;
use lightcraft_catalog::*;
use lightcraft_catalog::{Catalog, DEFAULT_FORGET_DAYS, FsStore, HistoryStep, Journal, MediaKind, Op, Photo, PhotoId, Source, folder_of};
use lightcraft_develop::DevelopSettings;
fn ms(t: Instant) -> f64 {
+16 -27
View File
@@ -904,17 +904,9 @@ fn search_text_finds_people_and_quoted_values() {
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
/// said photo by photo.
#[test]
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
fn photo_ops_record_touched_ids_and_changed_aspects() {
let mut c = Catalog::default();
let photo = |c: &mut Catalog, name: &str| {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
})
.unwrap();
id
};
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
let (a, b) = (photo(&mut c, "a.jpg", "2026-01-01T10:00:00"), photo(&mut c, "b.jpg", "2026-01-01T10:00:00"));
let start = c.revision;
assert_eq!(c.touched_since(start), Some(vec![]));
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
@@ -940,28 +932,25 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them()
assert_eq!(c.touched_since(before), None);
assert_eq!(c.aspects_since(start), None);
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
// a revision that is not this catalog's past
assert_eq!(c.touched_since(c.revision + 5), None);
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
// the next op nothing of before it
let old = c.revision;
}
#[test]
fn history_handles_loaded_revisions_failed_batches_and_retention() {
let mut c = Catalog::default();
let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00");
let album = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
// with an op that changes more than photos is not
let before = c.revision;
c.revision = 7 << 32;
assert_eq!(c.touched_since(old), None);
assert_eq!(c.touched_since(before), None);
assert_eq!(c.aspects_since(c.revision), None);
let loaded = c.revision;
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
assert_eq!(c.touched_since(loaded), Some(vec![a]));
assert_eq!(c.touched_since(old), None);
// an op that fails changes nothing and is not remembered
let before = c.revision;
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
assert_eq!(c.revision, before);
assert_eq!(c.touched_since(before), Some(vec![]));
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
assert_eq!(c.touched_since(before), Some(vec![a]));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
// with an op that changes more than photos is not
assert_eq!(c.touched_since(before), None);
let before = c.revision;
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
+82 -44
View File
@@ -227,7 +227,7 @@ pub enum TiffCompression {
/// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha.
/// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`).
pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result<Vec<u8>> {
use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype};
use tiff::encoder::{Compression, DeflateLevel, TiffEncoder};
use tiff::tags::Predictor;
img.validate()?;
let e = |e: tiff::TiffError| Error::Encode(e.to_string());
@@ -243,54 +243,92 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None };
let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor);
let (w, h) = (img.width, img.height);
let n = w as usize * h as usize;
// Gray + alpha is stored as RGBA (the encoder has no GrayA colour type).
let ch = img.channels as usize;
macro_rules! write {
($ct:ty, $data:expr, $alpha:expr) => {{
let mut im = enc.new_image::<$ct>(w, h).map_err(e)?;
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 });
}
if let Some(icc) = meta.icc {
im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?;
}
if let Some(xmp) = meta.xmp {
im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?;
}
if $alpha {
im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?;
}
im.write_data($data).map_err(e)?;
}};
}
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
}
let pixels = w as usize * h as usize;
match img.samples {
Samples::U8(s) => match ch {
1 => write!(colortype::Gray8, &s[..n], false),
2 => write!(colortype::RGBA8, &expand_ga(s, n), true),
3 => write!(colortype::RGB8, &s[..n * 3], false),
_ => write!(colortype::RGBA8, &s[..n * 4], true),
},
Samples::U16(s) => match ch {
1 => write!(colortype::Gray16, &s[..n], false),
2 => write!(colortype::RGBA16, &expand_ga(s, n), true),
3 => write!(colortype::RGB16, &s[..n * 3], false),
_ => write!(colortype::RGBA16, &s[..n * 4], true),
},
Samples::F32(s) => match ch {
1 => write!(colortype::Gray32Float, &s[..n], false),
2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true),
3 => write!(colortype::RGB32Float, &s[..n * 3], false),
_ => write!(colortype::RGBA32Float, &s[..n * 4], true),
},
Samples::U8(samples) => write_tiff_u8(&mut enc, samples, pixels, img.channels as usize, img, meta)?,
Samples::U16(samples) => write_tiff_u16(&mut enc, samples, pixels, img.channels as usize, img, meta)?,
Samples::F32(samples) => write_tiff_f32(&mut enc, samples, pixels, img.channels as usize, img, meta)?,
}
Ok(cur.into_inner())
}
macro_rules! write_tiff_image {
($encoder:expr, $image:expr, $meta:expr, $color:ty, $data:expr, $alpha:expr) => {{
let mut image = $encoder.new_image::<$color>($image.width, $image.height).map_err(|e| Error::Encode(e.to_string()))?;
if let Some(ppi) = $meta.ppi.filter(|ppi| *ppi > 0) {
image.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 });
}
if let Some(icc) = $meta.icc {
image.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(|e| Error::Encode(e.to_string()))?;
}
if let Some(xmp) = $meta.xmp {
image.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(|e| Error::Encode(e.to_string()))?;
}
if $alpha {
image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(|e| Error::Encode(e.to_string()))?;
}
image.write_data($data).map_err(|e| Error::Encode(e.to_string()))?;
}};
}
fn expanded_gray_alpha<T: Copy>(samples: &[T], pixels: usize) -> Vec<T> {
samples.as_chunks::<2>().0.iter().take(pixels).flat_map(|channel| [channel[0], channel[0], channel[0], channel[1]]).collect()
}
fn write_tiff_u8<W: std::io::Write + std::io::Seek>(
encoder: &mut tiff::encoder::TiffEncoder<W>,
samples: &[u8],
pixels: usize,
channels: usize,
image: &EncodeImage,
meta: &EncodeMeta,
) -> Result<()> {
use tiff::encoder::colortype;
match channels {
1 => write_tiff_image!(encoder, image, meta, colortype::Gray8, &samples[..pixels], false),
2 => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &expanded_gray_alpha(samples, pixels), true),
3 => write_tiff_image!(encoder, image, meta, colortype::RGB8, &samples[..pixels * 3], false),
_ => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &samples[..pixels * 4], true),
}
Ok(())
}
fn write_tiff_u16<W: std::io::Write + std::io::Seek>(
encoder: &mut tiff::encoder::TiffEncoder<W>,
samples: &[u16],
pixels: usize,
channels: usize,
image: &EncodeImage,
meta: &EncodeMeta,
) -> Result<()> {
use tiff::encoder::colortype;
match channels {
1 => write_tiff_image!(encoder, image, meta, colortype::Gray16, &samples[..pixels], false),
2 => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &expanded_gray_alpha(samples, pixels), true),
3 => write_tiff_image!(encoder, image, meta, colortype::RGB16, &samples[..pixels * 3], false),
_ => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &samples[..pixels * 4], true),
}
Ok(())
}
fn write_tiff_f32<W: std::io::Write + std::io::Seek>(
encoder: &mut tiff::encoder::TiffEncoder<W>,
samples: &[f32],
pixels: usize,
channels: usize,
image: &EncodeImage,
meta: &EncodeMeta,
) -> Result<()> {
use tiff::encoder::colortype;
match channels {
1 => write_tiff_image!(encoder, image, meta, colortype::Gray32Float, &samples[..pixels], false),
2 => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &expanded_gray_alpha(samples, pixels), true),
3 => write_tiff_image!(encoder, image, meta, colortype::RGB32Float, &samples[..pixels * 3], false),
_ => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &samples[..pixels * 4], true),
}
Ok(())
}
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
+18 -7
View File
@@ -14,6 +14,14 @@ pub struct Layout {
pub dy: usize,
}
/// The mosaic dimensions and cell origin for one square denoiser input tile.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TileRegion {
pub mosaic_size: (usize, usize),
pub cell_origin: (isize, isize),
pub tile_size: usize,
}
impl Layout {
/// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when
/// it is not a Bayer cell (one red, one blue, the greens on the other diagonal).
@@ -65,7 +73,10 @@ pub fn reflect(i: isize, n: usize) -> usize {
/// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]`
/// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside
/// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit.
pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool {
pub fn pack_tile(mosaic: &[f32], layout: Layout, region: TileRegion, out: &mut [f32]) -> bool {
let (width, height) = region.mosaic_size;
let (cx0, cy0) = region.cell_origin;
let tile = region.tile_size;
let Some(plane) = tile.checked_mul(tile) else { return false };
let Some(values) = plane.checked_mul(4) else { return false };
if width == 0
@@ -113,7 +124,7 @@ mod tests {
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
let mut out = [7.0; 4];
for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] {
assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out));
assert!(!pack_tile(&[0.5], layout, TileRegion { mosaic_size: (w, h), cell_origin: (x, 0), tile_size: tile }, &mut out));
assert_eq!(out, [7.0; 4]);
}
for n in [1, 2, isize::MAX as usize, usize::MAX] {
@@ -160,7 +171,7 @@ mod tests {
let (nx, ny) = layout.cells(w, h);
let tile = nx.max(ny);
let mut out = vec![0.0; 4 * tile * tile];
assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out));
assert!(pack_tile(&mosaic, layout, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: tile }, &mut out));
let plane = tile * tile;
for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() {
assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}");
@@ -174,7 +185,7 @@ mod tests {
let (w, h) = (4, 4);
let mosaic: Vec<f32> = (0..16).map(|i| i as f32).collect();
let mut out = vec![0.0; 4 * 4];
assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
assert!(pack_tile(&mosaic, Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: 2 }, &mut out));
// cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5
assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]);
// cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7
@@ -184,8 +195,8 @@ mod tests {
#[test]
fn packing_rejects_wrong_sizes() {
let mut out = vec![0.0; 16];
assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out));
assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
assert!(!pack_tile(&[0.0; 15], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 2 }, &mut out));
assert!(!pack_tile(&[0.0; 16], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 3 }, &mut out));
assert!(!pack_tile(&[], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (0, 0), cell_origin: (0, 0), tile_size: 2 }, &mut out));
}
}
+7 -2
View File
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use lightcraft_raster::Rgb32f;
use rayon::prelude::*;
use crate::bayer::{Layout, pack_tile};
use crate::bayer::{Layout, TileRegion, pack_tile};
use crate::manifest::Gain;
use crate::tiles::{starts, weight};
@@ -139,7 +139,12 @@ pub fn denoise_bayer(
.par_iter()
.map(|&(x0, y0, bx, ax, by, ay)| {
let mut input = allocate(4 * p.tile * p.tile, 0f32)?;
if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) {
if !pack_tile(
mosaic,
layout,
TileRegion { mosaic_size: (width, height), cell_origin: (x0 as isize, y0 as isize), tile_size: p.tile },
&mut input,
) {
return Err(Error::Input("a tile could not be packed".into()));
}
let mut out = runner.run(&input)?;
+100 -88
View File
@@ -91,107 +91,119 @@ fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64)
(max, sum as f64 / (a.data.len() * 3).max(1) as f64)
}
fn benchmark_jpeg(args: &[String], n: usize) {
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
for path in args {
let bytes = std::fs::read(path).expect("read");
for edge in [256u32, 512, 1600, 2560] {
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
println!(
" {path} fit {edge}: {}×{} {}",
d.image.width,
d.image.height,
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
);
}
}
}
fn benchmark_load(args: &[String], n: usize, only: &str) {
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
for path in args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
if std::env::var("LOADONLY").is_ok() {
// only the preview load (for a profiler)
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
if let lightcraft_raw::RawData::U16(d) = &raw.data {
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
println!(" decode checksum: {sum:016x}");
}
if only == "load1" {
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
}
for edge in [2560usize, 1600] {
println!(
" {:<36} {}",
format!("embedded srgb, whole file ({edge}):"),
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
);
let quick = lightcraft_engine::files::fs_preview_loader();
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
}
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
let (loader, _) = lightcraft_engine::files::fs_hooks();
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
}
}
fn benchmark_source(args: &[String], n: usize) {
// opening a photo: where the time goes before the first sharp loupe (per file)
for path in args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
let norm = raw.normalized().expect("norm");
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
println!(
" {:<36} {}",
"highlight reconstruct (binned):",
best(n, || {
let mut i = img.clone();
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
})
);
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
}
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
for edge in [512usize, 2560, usize::MAX] {
println!(
" {:<36} {}",
format!("load_bytes({edge}):"),
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
let only = std::env::var("ONLY").unwrap_or_default();
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
if only == "jpeg" {
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
for path in &args {
let bytes = std::fs::read(path).expect("read");
for edge in [256u32, 512, 1600, 2560] {
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
println!(
" {path} fit {edge}: {}×{} {}",
d.image.width,
d.image.height,
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
);
}
}
benchmark_jpeg(&args, n);
return;
}
if only == "load" || only == "load1" {
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
for path in &args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
if std::env::var("LOADONLY").is_ok() {
// only the preview load (for a profiler)
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
if let lightcraft_raw::RawData::U16(d) = &raw.data {
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
println!(" decode checksum: {sum:016x}");
}
if only == "load1" {
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
}
for edge in [2560usize, 1600] {
println!(
" {:<36} {}",
format!("embedded srgb, whole file ({edge}):"),
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
);
let quick = lightcraft_engine::files::fs_preview_loader();
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
}
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
let (loader, _) = lightcraft_engine::files::fs_hooks();
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
}
benchmark_load(&args, n, &only);
return;
}
if only == "source" {
// opening a photo: where the time goes before the first sharp loupe (per file)
for path in &args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
let norm = raw.normalized().expect("norm");
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
println!(
" {:<36} {}",
"highlight reconstruct (binned):",
best(n, || {
let mut i = img.clone();
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
})
);
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
}
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
for edge in [512usize, 2560, usize::MAX] {
println!(
" {:<36} {}",
format!("load_bytes({edge}):"),
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
);
}
}
benchmark_source(&args, n);
return;
}
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
let (full, info) = match args.first() {
Some(path) => {
let bytes = std::fs::read(path).expect("read");
+1 -1
View File
@@ -1,7 +1,7 @@
use super::look_fit::{fit_dng_tone, fit_preview};
use super::mono::{fit_dark_tone, monochrome_look};
use super::proxy::{LensGeometry, proxies};
use super::*;
use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford};
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
/// below were set at this size).
+1 -1
View File
@@ -1,6 +1,6 @@
use super::mono::to_prophoto;
use super::pairs::{displayed, luma};
use super::*;
use super::{CameraLook, CameraTone, HsvTable, Mat3, ToneMap, luminance_2020};
/// [`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
+1 -1
View File
@@ -3,7 +3,7 @@ use super::mono::{fit_tone_only_with, monochrome};
use super::pairs::{Attempt, TONE_FITS, fit_attempt, fit_partial, search_ordered};
use super::proxy::{CLIP_TOLERANCE, LensGeometry, MIN_PREVIEW_FRACTION, proxies, structure_agrees};
use super::tone::{HighlightCheck, highlights};
use super::*;
use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020};
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) {
+1 -1
View File
@@ -7,7 +7,7 @@ use super::pairs::{
};
use super::proxy::{LensGeometry, proxies};
use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold};
use super::*;
use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020};
/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey.
const GREY_SPREAD: f32 = 0.002;
+1 -1
View File
@@ -3,7 +3,7 @@ use super::proxy::DARK_FLOOR;
#[cfg(test)]
use super::tone::highlights;
use super::tone::{HighlightCheck, ToneFit, highlights_hold};
use super::*;
use super::{CameraLook, CameraTone, HsvTable, Mat3, Rgb32f, ToneMap, luminance_2020};
/// A fit must cut the held-out squared error to below this share of the fallback's.
pub(super) const MIN_IMPROVEMENT: f64 = 0.7;
+1 -1
View File
@@ -2,7 +2,7 @@ use super::colour::{PROFILE_PROXY, PROXY, file_local_look};
use super::hue_sat::fit_hue_sat;
use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped};
use super::pairs::{collect_pairs, fit_matrix};
use super::*;
use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize};
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
+1 -1
View File
@@ -1,6 +1,6 @@
use super::look_fit::dro_off;
use super::pairs::{Pairs, displayed, luma};
use super::*;
use super::{CameraTone, Rgb32f, ToneMap, luminance_2020};
/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+22 -9
View File
@@ -129,16 +129,18 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result<Source> {
/// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing
/// a model is how someone says they want it. `verified` is for a download whose hash was already checked.
fn install_file(
dir: &Path,
loader: &crate::denoise::Loader,
struct InstallFile<'a> {
dir: &'a Path,
run_on: RunOn,
accepted_at: String,
c: &str,
src: &Path,
command: &'a str,
src: &'a Path,
acknowledged: bool,
verified: bool,
) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
}
fn install_file(loader: &crate::denoise::Loader, request: InstallFile<'_>) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
let InstallFile { dir, run_on, accepted_at, command: c, src, acknowledged, verified } = request;
let ident = identify(src, c, None)?;
let m = ident.manifest.clone();
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
@@ -262,7 +264,8 @@ fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, veri
.spawn(move || {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024);
install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e))
install_file(&loader, InstallFile { dir: &dir, run_on, accepted_at: at, command: C, src: &src, acknowledged: true, verified })
.map_err(|e| plain(&e))
}))
.unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into()));
if verified {
@@ -290,8 +293,18 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
if s.denoise.install.is_some() {
return Err(bad(C, "another model is being installed"));
}
let (result, model) =
install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?;
let (result, model) = install_file(
&s.denoise.loader,
InstallFile {
dir: &models_dir(s, C)?,
run_on: s.denoise.settings.run_on(),
accepted_at: (s.clock)(),
command: C,
src: Path::new(path),
acknowledged,
verified: false,
},
)?;
activate_install(s, &result, model, activate)
}
+4 -1
View File
@@ -188,7 +188,10 @@ mod download {
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
use super::*;
use super::{
Cancel, DownloadOutcome, GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, PoisonError, Result, Session, Value, bad, dataset_dir, json,
str_param,
};
use crate::activity::Unit;
const MAX_ZIP: u64 = 64 * 1024 * 1024;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{AlbumId, LibrarySource, PhotoId, Result, Session, Value, bad};
/// `base` with a partial Filter (JSON) merged on top.
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
+1 -1
View File
@@ -1,5 +1,5 @@
use super::selection::strs;
use super::*;
use super::{AlbumId, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, str_param};
/// The auto-import folder's visible files with their sizes (the file-system half of
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{AlbumId, Flag, Op, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, ok};
/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it).
/// Its callers end an open edit (a slider drag) before their own change, as moving to the next
+4 -1
View File
@@ -5,7 +5,10 @@ use super::selection::{
album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted,
opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs,
};
use super::*;
use super::{
Album, AlbumId, ColorLabel, CommandSpec, CopyrightStatus, Filter, Flag, GroupBy, LibrarySource, Op, PhotoId, Result, Selection, Sort, SortKey,
Value, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, json, ok, str_param,
};
/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote.
const MAX_SEED: u64 = 1 << 53;
+17 -1
View File
@@ -37,9 +37,16 @@ fn props(x: &str) -> Props {
#[test]
fn maps_common_fields() {
let p = props(SIDECAR);
assert!(has_adjustments(&p));
let partial = to_partial(&p, Target::RawAbsolute);
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
assert!(has_adjustments(&p));
assert_common_tone_fields(&s);
assert_common_color_fields(&s);
assert_common_detail_fields(&s);
assert_common_geometry_fields(&s);
}
fn assert_common_tone_fields(s: &DevelopSettings) {
assert_eq!(s.wb.mode, WbMode::Custom);
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
assert_eq!(s.light.exposure, 0.65);
@@ -54,15 +61,24 @@ fn maps_common_fields() {
assert_eq!(s.curve.refine_saturation, 40.0);
assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9);
assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve");
}
fn assert_common_color_fields(s: &DevelopSettings) {
assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0));
assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0));
assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0));
assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0));
}
fn assert_common_detail_fields(s: &DevelopSettings) {
assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0));
assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0));
assert_eq!(s.vignette.amount, -21.0);
assert_eq!(s.vignette.style, VignetteStyle::ColorPriority);
assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0));
}
fn assert_common_geometry_fields(s: &DevelopSettings) {
assert_eq!(s.geometry.upright, Upright::Level);
assert_eq!(s.geometry.vertical, -10.0);
assert!(s.optics.remove_ca);
+1 -1
View File
@@ -1,7 +1,7 @@
#[cfg(feature = "denoise")]
use super::model::{GpuSide, STACK};
#[cfg(feature = "denoise")]
use super::*;
use super::{Arc, Duration, Mutex, Path, PathBuf, PoisonError, SystemTime, TileRunner};
/// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended.
#[cfg(feature = "denoise")]
+3 -2
View File
@@ -4,8 +4,9 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU
use super::settings::{installed_models, read_settings};
#[cfg(feature = "denoise")]
use super::state::Pace;
use super::*;
use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json};
#[cfg(feature = "denoise")]
use super::{AtomicBool, AtomicUsize, OnceLock, Ordering, PathBuf, PoisonError, RunError};
/// Stack of the threads that run the model (the runtime recurses through its graph).
pub(super) const STACK: usize = 16 << 20;
+4 -1
View File
@@ -1,6 +1,9 @@
use super::model::{Loader, Model, ModelSlot, RunOn, STACK};
use super::settings::EXT;
use super::*;
use super::{
Arc, AtomicBool, AtomicUsize, ByteReader, Condvar, Control, DenoiserManifest, HashSet, Hasher128, Layout, Mutex, OnceLock, Ordering, Params,
Path, PathBuf, Photo, PoisonError, RunError, Source, SystemTime, denoise_bayer, product,
};
/// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them.
const CLIP: f32 = 0.99;
+4 -1
View File
@@ -4,7 +4,10 @@ use super::model::STACK;
use super::products::{Active, JobSpec, MakeError, Progress, eligible, make_product, product_key, product_path, products_in};
use super::settings::{DIR, SETTINGS_TTL, installed_models, read_settings};
use super::state::{AUTO_LOOK, Failure, MAX_QUEUE, Outcome, Pace, PhotoState, Running};
use super::*;
use super::{
Arc, DenoiseSpec, DevelopSettings, HashMap, HashSet, MakeProduct, Mutex, Ordering, PairLoader, Path, PathBuf, Photo, PhotoId, PoisonError,
Selection, Session, Source, Value, json, product,
};
impl Session {
/// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded.
+1 -1
View File
@@ -1,6 +1,6 @@
use super::gpu::GPU_SETUP_MARKER;
use super::model::RunOn;
use super::*;
use super::{DenoiserManifest, Deserialize, Duration, Path, PathBuf, Serialize, Value};
/// Extension of a product file.
pub const EXT: &str = "lcdn";
+1 -1
View File
@@ -1,7 +1,7 @@
use super::model::{Loader, default_loader};
use super::products::{Active, Progress};
use super::settings::Settings;
use super::*;
use super::{Arc, Downloads, HashMap, Mutex, PathBuf, PhotoId, Serialize, VecDeque};
/// Most photos waiting in the queue.
pub(super) const MAX_QUEUE: usize = 5000;
+1 -1
View File
@@ -1,6 +1,6 @@
use super::ExportOptions;
use super::place::Placer;
use super::prepare::{Destination, Exported, PreparedExport};
use super::*;
/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to
/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its
+4 -1
View File
@@ -1,6 +1,9 @@
use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor};
use super::watermark::{Watermark, draw_watermark, draw_watermark_deep};
use super::*;
use super::{
ChromaSubsampling, DateTime, DeepImage, DeepSamples, EncodeImage, EncodeMeta, ExportOptions, Gps, Metadata, NamedSpace, OutputDepth, OutputSpace,
Rgba8, Samples, encode, icc,
};
use std::borrow::Cow;
/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
+1 -1
View File
@@ -1,7 +1,7 @@
use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor};
use super::prepare::DngCompression;
use super::watermark::{Anchor, Watermark};
use super::*;
use super::{ExportOptions, JxlColour, JxlEffort, JxlOptions, OutputDepth, OutputSpace, TiffCompression};
impl Default for ExportOptions {
fn default() -> Self {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::ExportOptions;
use super::options::{Conflict, DestinationMode};
use super::prepare::{Destination, Exported};
use super::*;
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file
+1 -1
View File
@@ -2,7 +2,7 @@ use super::batch::run_batch;
use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location};
use super::options::ExportFormat;
use super::params::apple_photos_unsupported;
use super::*;
use super::{ExportOptions, Metadata, OutputDepth};
/// One exported file.
pub struct Exported {
+10
View File
@@ -1067,6 +1067,11 @@ fn avif_ten_bit() {
/// unknown keys (naming the nearest known one) and values of the wrong kind or range.
#[test]
fn export_params_are_validated() {
assert_invalid_export_params();
assert_export_options_round_trip();
}
fn assert_invalid_export_params() {
use serde_json::json;
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
let e = err(json!({"longEdgee": 400}));
@@ -1092,6 +1097,11 @@ fn export_params_are_validated() {
.to_string()
.starts_with("invalid parameters for `export.savePreset`")
);
}
fn assert_export_options_round_trip() {
use serde_json::json;
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
// everything the presets, the dialog, Export with Previous and `render --opt` send passes
for p in builtin_presets() {
ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name));
+1 -1
View File
@@ -1,5 +1,5 @@
use super::encoding::srgb8_in;
use super::*;
use super::{DeepImage, DeepSamples, Deserialize, OutputSpace, Rgba8, Serialize};
/// Where a watermark sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+6 -8
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError};
use std::time::Duration;
use lightcraft_catalog::PhotoId;
use lightcraft_faces::align::{Rgb, align_to_template, crop_box};
use lightcraft_faces::align::{FaceCrop, Rgb, align_to_template, crop_box};
use lightcraft_faces::runtime::Embedder;
use lightcraft_faces::yunet::{Detector, Face, Options};
use lightcraft_geom::Rect;
@@ -221,13 +221,11 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
Some((_, f)) => align_to_template(&img, &scaled(f), iw, ih),
None => crop_box(
&img,
rect.x0 as f32 * w as f32,
rect.y0 as f32 * h as f32,
rect.x1 as f32 * w as f32,
rect.y1 as f32 * h as f32,
BOX_PAD,
iw,
ih,
FaceCrop {
bounds: (rect.x0 as f32 * w as f32, rect.y0 as f32 * h as f32, rect.x1 as f32 * w as f32, rect.y1 as f32 * h as f32),
padding: BOX_PAD,
output_size: (iw, ih),
},
),
};
// shown by the detector's box where it found this face: every face then has the same tightness
+1 -1
View File
@@ -1,7 +1,7 @@
use super::load::{load_vec, load_vec_pair, load_vec_reduced};
use super::preview::{fit_box, fs_preview_loader, preview_orientation};
use super::probe::probe_bytes;
use super::*;
use super::{Arc, DenoiseSpec, FileLoader, FileProbe, Filter, PairLoader, ReducedLoader, fit};
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
+1 -1
View File
@@ -1,7 +1,7 @@
use super::colour::bin_factor;
use super::preview::{fit_box, load_embedded_preview};
use super::probe::preview_reason;
use super::*;
use super::{Arc, CameraColourModel, DenoiseSpec, Filter, HIGHLIGHT_CLIP, Orientation, Rgb32f, SourceInfo, fit, xy_to_temp_tint};
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
///
+1 -1
View File
@@ -1,5 +1,5 @@
use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at};
use super::*;
use super::{Arc, Filter, Orientation, PreviewLoader, Rgb32f, SourceInfo, fit};
/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
pub(super) fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::load::WbScaleOf;
use super::preview::embedded_preview_size;
use super::*;
use super::{MediaKind, Meta, Orientation, ProbeInfo};
pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
+1 -1
View File
@@ -2,7 +2,7 @@ use super::defaults::apply_import_defaults;
use super::files::copy_into;
use super::job::{Prepared, PreparedItem, ReadyFile};
use super::types::{Duplicate, ImportOptions, ImportReport, Kept, Moved, OnDeleted};
use super::*;
use super::{HashMap, Op, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source};
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
#[derive(Default)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Photo, Serialize, Session};
/// 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
+2 -1
View File
@@ -5,7 +5,7 @@ use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile};
use super::scan::ScanProgress;
#[cfg(test)]
use super::types::{ImportMode, ImportOptions};
use super::*;
use super::{Path, PathBuf, ProbeInfo, Session};
/// File extensions offered by import discovery and file pickers (lower case).
/// This includes preview-only raws; the file probe decides whether sensor decoding is available.
@@ -253,6 +253,7 @@ pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: O
#[cfg(test)]
mod prepared_tests {
use super::*;
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
/// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the
/// files this build cannot import (see [`accept_list`]).
+1 -1
View File
@@ -1,7 +1,7 @@
use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock};
use super::files::{expand, probe_all};
use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted};
use super::*;
use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source};
/// Import files/folders. See the module docs.
pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result<ImportReport> {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::files::{expand, probe_all};
use super::types::ImportCandidate;
use super::*;
use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source};
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
/// share can take minutes to read).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, MediaKind, Photo, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ImportMode {
+79 -67
View File
@@ -858,6 +858,83 @@ pub(crate) struct ApplyPrepared<'a> {
pub(crate) undo0: usize,
}
fn stage_collections(
s: &mut crate::Session,
data: &CatalogImport,
index: &mut ImportIndex,
ids: &HashMap<i64, PhotoId>,
ops: &mut Vec<Op>,
warnings: &mut Vec<String>,
) -> usize {
let mut albums = HashMap::new();
for collection in &data.collections {
if number(collection, "systemOnly") != 0 {
continue;
}
let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local"));
let id = index
.collections
.get(&collection_key)
.copied()
.map(lightcraft_catalog::AlbumId)
.filter(|id| s.catalog.album(*id).is_some())
.unwrap_or_else(|| s.catalog.alloc_album_id());
index.collections.insert(collection_key, id.0);
albums.insert(number(collection, "id_local"), id);
}
let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect();
let mut grouped_members: HashMap<i64, Vec<&Row>> = HashMap::new();
for member in &data.members {
grouped_members.entry(number(member, "collection")).or_default().push(member);
}
for members in grouped_members.values_mut() {
members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection")));
}
let mut ordered: Vec<_> = data.collections.iter().collect();
ordered.sort_by_key(|r| {
let mut depth = 0;
let mut at = number(r, "parent");
while at != 0 && depth < 64 {
depth += 1;
at = parents.get(&at).copied().unwrap_or(0);
}
depth
});
for collection in ordered {
let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue };
let mut album = Album::new(id, text(collection, "name"));
album.parent = albums.get(&number(collection, "parent")).copied();
album.folder = text(collection, "creationId").contains("group");
if text(collection, "creationId").contains("smart") {
warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name));
}
if !album.folder {
album.photos = grouped_members
.get(&number(collection, "id_local"))
.into_iter()
.flatten()
.filter_map(|r| ids.get(&number(r, "image")).copied())
.collect();
}
if s.catalog.album(id).is_some() {
// Reimport preserves the user's album name/hierarchy, merges only new membership.
if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) {
let mut photos = old.photos.clone();
let mut seen: HashSet<_> = photos.iter().copied().collect();
for photo in album.photos {
if seen.insert(photo) {
photos.push(photo);
}
}
ops.push(Op::SetAlbumPhotos { id, photos });
}
} else {
ops.push(Op::AddAlbum { album });
}
}
albums.len()
}
/// Turn the read catalog into library records in one undo step and return the import report
/// (added, missing, failed, warnings and skipped images).
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
@@ -1022,78 +1099,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
}
by_path.entry(key(&src.path)).or_insert(photo_id);
}
let mut albums = HashMap::new();
for collection in &data.collections {
if number(collection, "systemOnly") != 0 {
continue;
}
let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local"));
let id = index
.collections
.get(&collection_key)
.copied()
.map(lightcraft_catalog::AlbumId)
.filter(|id| s.catalog.album(*id).is_some())
.unwrap_or_else(|| s.catalog.alloc_album_id());
index.collections.insert(collection_key, id.0);
albums.insert(number(collection, "id_local"), id);
}
let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect();
let mut grouped_members: HashMap<i64, Vec<&Row>> = HashMap::new();
for member in &data.members {
grouped_members.entry(number(member, "collection")).or_default().push(member);
}
for members in grouped_members.values_mut() {
members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection")));
}
let mut ordered: Vec<_> = data.collections.iter().collect();
ordered.sort_by_key(|r| {
let mut depth = 0;
let mut at = number(r, "parent");
while at != 0 && depth < 64 {
depth += 1;
at = parents.get(&at).copied().unwrap_or(0);
}
depth
});
for collection in ordered {
let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue };
let mut album = Album::new(id, text(collection, "name"));
album.parent = albums.get(&number(collection, "parent")).copied();
album.folder = text(collection, "creationId").contains("group");
if text(collection, "creationId").contains("smart") {
warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name));
}
if !album.folder {
album.photos = grouped_members
.get(&number(collection, "id_local"))
.into_iter()
.flatten()
.filter_map(|r| ids.get(&number(r, "image")).copied())
.collect();
}
if s.catalog.album(id).is_some() {
// Reimport preserves the user's album name/hierarchy, merges only new membership.
if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) {
let mut photos = old.photos.clone();
let mut seen: HashSet<_> = photos.iter().copied().collect();
for photo in album.photos {
if seen.insert(photo) {
photos.push(photo);
}
}
ops.push(Op::SetAlbumPhotos { id, photos });
}
} else {
ops.push(Op::AddAlbum { album });
}
}
let album_count = stage_collections(s, &data, index, &ids, &mut ops, &mut warnings);
if !ops.is_empty() {
s.commit("Import Lightroom Catalog", Op::Batch { ops })?;
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":album_count,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::settings_hash::SettingsHashes;
use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES};
use super::*;
use super::{Arc, DevelopSettings, Hasher128, Lru, MediaCache, Photo, PhotoId, PreviewCache, Rgb32f, Source, SourceInfo};
/// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the
/// file system, so it can be asked on the UI thread.
+1 -1
View File
@@ -1,6 +1,6 @@
use super::render::{RenderJob, RenderResult};
use super::source::{PreviewLoader, SourceLevel};
use super::*;
use super::{Arc, Deserialize, Hash128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo};
/// Where a [`QuickJob`]'s stand-in image came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+3 -1
View File
@@ -1,6 +1,8 @@
use super::quick::QuickSource;
use super::source::{DecodedSource, SourceLevel, SourceRef};
use super::*;
use super::{
Arc, DevelopSettings, Hash128, Histogram, PhotoId, PreviewCache, Quality, RenderRequest, Rendered, Rgb32f, Rgba8, Source, SourceInfo, StageCache,
};
/// Everything needed to render one photo, detached from the session.
#[derive(Clone)]
+1 -1
View File
@@ -1,7 +1,7 @@
use super::quick::{QuickJob, content_key, source_info};
use super::render::{RenderJob, RenderResult};
use super::source::{PreviewLoader, RENDER_CACHE_VERSION, SourceLevel, SourceRef, THUMB_SIZES, face_edge, source_edge_needed, thumb_bucket};
use super::*;
use super::{Arc, DevelopSettings, Hash128, Hasher128, MediaKind, Op, Photo, PhotoId, RenderRequest, Rendered, Rgb32f, Source, SourceInfo};
impl crate::Session {
/// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, DevelopSettings, HashMap, PhotoId, VecDeque, Weak};
pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 70;
+15 -4
View File
@@ -255,6 +255,15 @@ pub struct Image16 {
pub rgb: Vec<u16>,
}
/// A rectangular image region used as a source or destination for `Image16::blit`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BlitRect {
pub x: usize,
pub y: usize,
pub w: usize,
pub h: usize,
}
impl Image16 {
pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> {
let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?;
@@ -284,9 +293,11 @@ impl Image16 {
}
/// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images.
pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) {
let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
pub fn blit(&mut self, src: &Image16, source: BlitRect, destination: BlitRect) {
let BlitRect { x: sx, y: sy, .. } = source;
let BlitRect { x: dx, y: dy, .. } = destination;
let w = source.w.min(destination.w).min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
let h = source.h.min(destination.h).min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
for row in 0..h {
let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3);
let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3);
@@ -424,7 +435,7 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
if border > 0 {
page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border);
}
page.blit(&img, sx, sy, dx, dy, w, h);
page.blit(&img, BlitRect { x: sx, y: sy, w, h }, BlitRect { x: dx, y: dy, w, h });
let s = style.stroke_px.min(w.min(h) / 2);
if s > 0 {
page.fill_rect(dx, dy, w, s, style.stroke);
+2 -2
View File
@@ -182,9 +182,9 @@ fn rotate_and_blit_are_clipped() {
let r = img.rotated_cw().unwrap();
assert_eq!((r.w, r.h), (2, 3));
let mut p = page();
p.blit(&img, 0, 0, 99, 99, 10, 10);
p.blit(&img, BlitRect { x: 0, y: 0, w: 10, h: 10 }, BlitRect { x: 99, y: 99, w: 10, h: 10 });
assert_eq!(at(&p, 99, 99), at(&img, 0, 0));
p.blit(&img, 5, 5, 0, 0, 3, 3);
p.blit(&img, BlitRect { x: 5, y: 5, w: 3, h: 3 }, BlitRect { x: 0, y: 0, w: 3, h: 3 });
p.fill_rect(95, 95, 50, 50, RED);
assert_eq!(at(&p, 99, 99), RED);
p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED);
+1 -1
View File
@@ -1,5 +1,5 @@
use super::sidecar::PacketVersion;
use super::*;
use super::{Path, PathBuf, SidecarNaming};
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars};
use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases};
use super::*;
use super::{HashMap, HashSet, Op, Path, PathBuf, SidecarNaming, Source};
/// Why [`move_file`] failed, and what it left behind.
#[derive(Clone, Debug, PartialEq)]
+1 -1
View File
@@ -2,7 +2,7 @@ use super::fs::{MoveFs, RealFs, folded_path};
use super::moves::{BatchMoveError, move_all, set_file_ops_to};
use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths};
use super::tokens::{expand, split_ext};
use super::*;
use super::{EngineError, HashMap, HashSet, Op, Path, PathBuf, Photo, PhotoId, RenamePlan, Result, Session, Source};
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
/// rename once. No file-system calls.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::fs::{MoveFs, is_respelling, move_one};
use super::*;
use super::{Path, PathBuf};
/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may
/// already have followed its new path, so a retry cannot infer this work from the catalog.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{MONTHS, Path, Photo, PhotoId, Serialize, Source};
pub(super) fn split_ext(name: &str) -> (&str, &str) {
match name.rsplit_once('.') {
@@ -73,85 +73,85 @@ fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions
for upper_full in [false, true] {
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for auto in [false, true] {
let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}"));
let a = dir.join("a.jpg");
let b = dir.join("Trip.jpg");
std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap();
let path = |photo: &Path, style, upper| {
let path = sidecar_path(&photo.to_string_lossy(), style);
if upper { path.with_extension("XMP") } else { path }
};
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
let full_a = path(&a, SidecarNaming::Full, upper_full);
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
let full_b = path(&b, SidecarNaming::Full, upper_full);
let stem_bytes = packet("stem-keep");
let full_bytes = packet("full-keep");
std::fs::write(&stem_a, &stem_bytes).unwrap();
std::fs::write(&full_a, &full_bytes).unwrap();
let mut s = session(&a);
s.xmp.naming = naming;
s.xmp.auto_write = auto;
let fs = Faults(vec![full_a.clone(), b.clone()]);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
let pending = queues(&s);
assert_eq!(pending.len(), 1);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
// Missing packet and an unrelated packet at either case spelling remain protected.
let held = dir.join("held.xmp");
std::fs::rename(&full_a, &held).unwrap();
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
assert_eq!(queues(&s), pending);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
std::fs::rename(&held, &full_a).unwrap();
for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] {
std::fs::write(&collision, packet("unrelated-packet")).unwrap();
let occupied = std::fs::read(&collision).unwrap();
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
assert_eq!(queues(&s), pending);
assert_eq!(std::fs::read(&collision).unwrap(), occupied);
assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
std::fs::remove_file(collision).unwrap();
}
// AutoWrite is the first successful repair when enabled; explicit save otherwise.
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
if !auto {
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0);
}
assert!(queues(&s).is_empty());
assert!(!full_a.exists());
let (written, untouched, expected) =
if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) };
assert_fresh(written);
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4));
for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] {
s.execute(command, &json!({})).unwrap();
if !auto {
save(&mut s);
}
assert_fresh(written);
assert_eq!(
parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating,
Some(rating)
);
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
}
assert_eq!(s.xmp.naming, naming);
assert_eq!(s.xmp.auto_write, auto);
drop(s);
std::fs::remove_dir_all(dir).unwrap();
check_companion_scenario(upper_stem, upper_full, naming, auto);
}
}
}
}
}
fn check_companion_scenario(upper_stem: bool, upper_full: bool, naming: SidecarNaming, auto: bool) {
let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}"));
let a = dir.join("a.jpg");
let b = dir.join("Trip.jpg");
std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap();
let path = |photo: &Path, style, upper| {
let path = sidecar_path(&photo.to_string_lossy(), style);
if upper { path.with_extension("XMP") } else { path }
};
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
let full_a = path(&a, SidecarNaming::Full, upper_full);
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
let full_b = path(&b, SidecarNaming::Full, upper_full);
let stem_bytes = packet("stem-keep");
let full_bytes = packet("full-keep");
std::fs::write(&stem_a, &stem_bytes).unwrap();
std::fs::write(&full_a, &full_bytes).unwrap();
let mut s = session(&a);
s.xmp.naming = naming;
s.xmp.auto_write = auto;
let fs = Faults(vec![full_a.clone(), b.clone()]);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
let pending = queues(&s);
assert_eq!(pending.len(), 1);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
// Missing packet and an unrelated packet at either case spelling remain protected.
let held = dir.join("held.xmp");
std::fs::rename(&full_a, &held).unwrap();
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
assert_eq!(queues(&s), pending);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
std::fs::rename(&held, &full_a).unwrap();
for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] {
std::fs::write(&collision, packet("unrelated-packet")).unwrap();
let occupied = std::fs::read(&collision).unwrap();
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
assert_eq!(queues(&s), pending);
assert_eq!(std::fs::read(&collision).unwrap(), occupied);
assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
std::fs::remove_file(collision).unwrap();
}
// AutoWrite is the first successful repair when enabled; explicit save otherwise.
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
if !auto {
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0);
}
assert!(queues(&s).is_empty());
assert!(!full_a.exists());
let (written, untouched, expected) =
if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) };
assert_fresh(written);
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4));
for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] {
s.execute(command, &json!({})).unwrap();
if !auto {
save(&mut s);
}
assert_fresh(written);
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(rating));
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
}
assert_eq!(s.xmp.naming, naming);
assert_eq!(s.xmp.auto_write, auto);
drop(s);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rollback_repair_and_shared_copy_keep_known_companions() {
for rollback in [false, true] {
@@ -348,64 +348,12 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() {
// A private-method contract, not a UI reproduction: application reachability of two
// simultaneous repair owners is unproven. Native move failures and follow_stuck seed
// both records; the second owner's normal apply_rename preflight is deliberately bypassed.
struct NativePartial {
id: PhotoId,
original: PathBuf,
moved: PathBuf,
stem: PathBuf,
full: PathBuf,
companion: PathBuf,
target: PathBuf,
}
let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract");
let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]);
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
let mut s = Session::new();
s.xmp.naming = SidecarNaming::Stem;
let mut native = Vec::new();
for (i, name) in ["first", "second"].into_iter().enumerate() {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("a.png");
let moved = dir.join("Trip.png");
std::fs::write(&original, &png).unwrap();
let stem = path(&original, SidecarNaming::Stem, false);
let full = path(&original, SidecarNaming::Full, true);
for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] {
std::fs::write(p, packet(&marker)).unwrap();
assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5));
}
let id = PhotoId(i as u64 + 1);
let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
let fs = Faults(vec![full.clone(), moved.clone()]);
if i == 0 {
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
assert!(s.apply_rename_with(&fs, &plans).is_err());
} else {
// Register the genuine second partial move without draining the first repair.
let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())];
let err = crate::rename::move_all(&fs, &moves).unwrap_err();
assert_eq!(err.stuck, moves);
assert_eq!(err.pending_sidecars.len(), 1);
let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() };
s.follow_stuck(&op, err, true);
}
assert_eq!(s.undo.len(), i + 1);
assert_eq!(queued(&s).len(), i + 1);
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() });
let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), (4, 3));
native.push(NativePartial {
id,
original,
companion: path(&moved, SidecarNaming::Stem, false),
target: path(&moved, SidecarNaming::Full, true),
moved,
stem,
full,
});
}
let native = create_native_partials(&root, &png, &mut s);
let first = &native[0];
let second = &native[1];
let first_stem = std::fs::read(&first.companion).unwrap();
@@ -463,3 +411,61 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() {
drop(s);
std::fs::remove_dir_all(root).unwrap();
}
struct NativePartial {
id: PhotoId,
original: PathBuf,
moved: PathBuf,
stem: PathBuf,
full: PathBuf,
companion: PathBuf,
target: PathBuf,
}
fn create_native_partials(root: &Path, png: &[u8], s: &mut Session) -> Vec<NativePartial> {
let mut native = Vec::new();
for (i, name) in ["first", "second"].into_iter().enumerate() {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("a.png");
let moved = dir.join("Trip.png");
std::fs::write(&original, png).unwrap();
let stem = path(&original, SidecarNaming::Stem, false);
let full = path(&original, SidecarNaming::Full, true);
for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] {
std::fs::write(p, packet(&marker)).unwrap();
assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5));
}
let id = PhotoId(i as u64 + 1);
let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
let fs = Faults(vec![full.clone(), moved.clone()]);
if i == 0 {
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
assert!(s.apply_rename_with(&fs, &plans).is_err());
} else {
// Register the genuine second partial move without draining the first repair.
let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())];
let err = crate::rename::move_all(&fs, &moves).unwrap_err();
assert_eq!(err.stuck, moves);
assert_eq!(err.pending_sidecars.len(), 1);
let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() };
s.follow_stuck(&op, err, true);
}
assert_eq!(s.undo.len(), i + 1);
assert_eq!(queued(s).len(), i + 1);
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() });
let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), (4, 3));
native.push(NativePartial {
id,
original,
companion: path(&moved, SidecarNaming::Stem, false),
target: path(&moved, SidecarNaming::Full, true),
moved,
stem,
full,
});
}
native
}
+110 -106
View File
@@ -58,118 +58,122 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw
for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for upper in [false, true] {
for switch in [false, true] {
let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}"));
let a = root.join("a.png");
let b = root.join("Trip.png");
png(&a);
let mut old = sidecar_path(&a.to_string_lossy(), naming);
if upper {
old = old.with_extension("XMP");
}
std::fs::write(&old, FOREIGN).unwrap();
let mut s = Session::new().with_fs();
s.open_library(root.join("library-a"), false).unwrap();
s.execute("library.import", &json!({"paths": [a]})).unwrap();
let mut id = s.catalog.photos().next().unwrap().id;
s.selection = crate::Selection::single(id);
s.undo.clear();
let fs = PartialRename { sidecar: old.clone(), photo: b.clone() };
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(queued(&s).len(), 1);
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
let pending = queued(&s);
let held = root.join("temporarily-unavailable.xmp");
std::fs::rename(&old, &held).unwrap();
if switch {
s.close_library().unwrap();
s.open_library(root.join("library-b"), false).unwrap();
assert_eq!(s.pending_sidecar_repairs, pending);
s.execute("library.import", &json!({"paths": [b]})).unwrap();
id = s.catalog.photos().next().unwrap().id;
s.selection = crate::Selection::single(id);
}
s.xmp.naming = save_naming;
s.xmp.auto_write = true;
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
let preferred = sidecar_path(&b.to_string_lossy(), save_naming);
assert!(!preferred.exists() && !preferred.with_extension("XMP").exists());
assert!(!pending[0].to.exists());
assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite");
let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
assert!(saved["written"].as_array().unwrap().is_empty());
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair"));
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
assert_eq!(queued(&s), pending);
// A missing sidecar for another photo does not prevent this healthy save.
let healthy = root.join("healthy.png");
png(&healthy);
let healthy_id = PhotoId(999);
s.commit(
"Healthy Photo",
Op::AddPhoto {
photo: Box::new(Photo::new(
healthy_id,
Source::File { path: healthy.to_string_lossy().into_owned() },
"healthy.png",
"PNG",
4,
3,
"",
)),
},
)
.unwrap();
s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap();
let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap();
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save"));
assert_eq!(queued(&s), pending);
// An existing destination is never overwritten and the original packet remains.
std::fs::rename(&held, &old).unwrap();
let destination = pending[0].to.clone();
let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes();
let collision_path = if upper { preferred.clone() } else { destination.clone() };
std::fs::write(&collision_path, &collision).unwrap();
s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap();
assert_eq!(std::fs::read(&old).unwrap(), FOREIGN);
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
assert_eq!(queued(&s), pending);
s.selection = crate::Selection::single(id);
assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1);
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision
// The next successful AutoWrite runs the real repair and production XMP merge.
s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap();
assert!(queued(&s).is_empty());
assert!(!old.exists());
let packet = std::fs::read_to_string(&destination).unwrap();
assert!(packet.contains("fixture:Marker=\"keep-me\""));
let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap();
assert_eq!(parsed.rating, Some(4));
assert_eq!(parsed.title.as_deref(), Some("Final title"));
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
if upper {
// by listing: on a case-insensitive volume `preferred` answers for the upper-case packet
let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
assert!(!listed, "merge uses the repaired uppercase packet");
}
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
s.execute(step, &json!({})).unwrap();
let packet = std::fs::read_to_string(&destination).unwrap();
assert!(packet.contains("fixture:Marker=\"keep-me\""));
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title));
}
drop(s);
std::fs::remove_dir_all(root).unwrap();
check_autowrite_repair_scenario(naming, save_naming, upper, switch);
}
}
}
}
}
fn check_autowrite_repair_scenario(naming: SidecarNaming, save_naming: SidecarNaming, upper: bool, switch: bool) {
let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}"));
let a = root.join("a.png");
let b = root.join("Trip.png");
png(&a);
let mut old = sidecar_path(&a.to_string_lossy(), naming);
if upper {
old = old.with_extension("XMP");
}
std::fs::write(&old, FOREIGN).unwrap();
let mut s = Session::new().with_fs();
s.open_library(root.join("library-a"), false).unwrap();
s.execute("library.import", &json!({"paths": [a]})).unwrap();
let mut id = s.catalog.photos().next().unwrap().id;
s.selection = crate::Selection::single(id);
s.undo.clear();
let fs = PartialRename { sidecar: old.clone(), photo: b.clone() };
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(queued(&s).len(), 1);
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
let pending = queued(&s);
let held = root.join("temporarily-unavailable.xmp");
std::fs::rename(&old, &held).unwrap();
if switch {
s.close_library().unwrap();
s.open_library(root.join("library-b"), false).unwrap();
assert_eq!(s.pending_sidecar_repairs, pending);
s.execute("library.import", &json!({"paths": [b]})).unwrap();
id = s.catalog.photos().next().unwrap().id;
s.selection = crate::Selection::single(id);
}
s.xmp.naming = save_naming;
s.xmp.auto_write = true;
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
let preferred = sidecar_path(&b.to_string_lossy(), save_naming);
assert!(!preferred.exists() && !preferred.with_extension("XMP").exists());
assert!(!pending[0].to.exists());
assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite");
let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
assert!(saved["written"].as_array().unwrap().is_empty());
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair"));
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
assert_eq!(queued(&s), pending);
// A missing sidecar for another photo does not prevent this healthy save.
let healthy = root.join("healthy.png");
png(&healthy);
let healthy_id = PhotoId(999);
s.commit(
"Healthy Photo",
Op::AddPhoto {
photo: Box::new(Photo::new(healthy_id, Source::File { path: healthy.to_string_lossy().into_owned() }, "healthy.png", "PNG", 4, 3, "")),
},
)
.unwrap();
s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap();
let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap();
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save"));
assert_eq!(queued(&s), pending);
// An existing destination is never overwritten and the original packet remains.
std::fs::rename(&held, &old).unwrap();
let destination = pending[0].to.clone();
let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes();
let collision_path = if upper { preferred.clone() } else { destination.clone() };
std::fs::write(&collision_path, &collision).unwrap();
s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap();
assert_eq!(std::fs::read(&old).unwrap(), FOREIGN);
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
assert_eq!(queued(&s), pending);
s.selection = crate::Selection::single(id);
assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1);
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision
// The next successful AutoWrite runs the real repair and production XMP merge.
s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap();
assert!(queued(&s).is_empty());
assert!(!old.exists());
let packet = std::fs::read_to_string(&destination).unwrap();
assert!(packet.contains("fixture:Marker=\"keep-me\""));
let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap();
assert_eq!(parsed.rating, Some(4));
assert_eq!(parsed.title.as_deref(), Some("Final title"));
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
assert_repaired_case_spelling(&root, &preferred, upper);
assert_undo_redo_preserve_foreign_packet(&mut s, &destination);
drop(s);
std::fs::remove_dir_all(root).unwrap();
}
fn assert_repaired_case_spelling(root: &Path, preferred: &Path, upper: bool) {
if !upper {
return;
}
// By listing, a case-insensitive volume answers for the uppercase packet.
let listed = std::fs::read_dir(root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
assert!(!listed, "merge uses the repaired uppercase packet");
}
fn assert_undo_redo_preserve_foreign_packet(s: &mut Session, destination: &Path) {
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
s.execute(step, &json!({})).unwrap();
let packet = std::fs::read_to_string(destination).unwrap();
assert!(packet.contains("fixture:Marker=\"keep-me\""));
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title));
}
}
#[test]
fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() {
let root = crate::tests_xmp::temp_dir("repair-save-shared-stem");
+116 -82
View File
@@ -1,7 +1,11 @@
//! Import: recursive folders, duplicate detection by content, add vs copy into the library.
use std::path::Path;
use std::{
path::{Path, PathBuf},
sync::atomic::AtomicUsize,
};
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use crate::Session;
@@ -1711,109 +1715,139 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() {
}
fn smart_look_refresh_for(format: &str) {
use std::sync::atomic::{AtomicUsize, Ordering};
let src = temp_dir(&format!("smartlook-src-{format}"));
let lib = temp_dir(&format!("smartlook-lib-{format}"));
write_png(&src.join("a.png"), 3);
write_png(&src.join("b.png"), 9);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
let (id, png) = (find(&s, "a.png"), find(&s, "b.png"));
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
raw.format = format.into();
raw.kind = lightcraft_catalog::MediaKind::Raw;
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW");
assert!(!s.catalog.photo(png).unwrap().relative_wb());
s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap();
let dir = s.media.smart_dir.clone().unwrap();
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap());
let (old, new) = (test_tone(1.5), test_tone(3.5));
let calls = std::sync::Arc::new(AtomicUsize::new(0));
let plain = s.media.file_loader.clone().unwrap();
s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone()));
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
let mut case = SmartLookCase::new(format);
assert_current_smart_look_is_not_rebuilt(&mut case);
let (old_bytes, png_bytes) = keep_offline_previews(&mut case);
refresh_online_previews(&mut case, &old_bytes, &png_bytes);
let _ = std::fs::remove_dir_all(&case.src);
let _ = std::fs::remove_dir_all(&case.lib);
}
struct SmartLookCase {
src: PathBuf,
lib: PathBuf,
session: Session,
id: PhotoId,
dir: PathBuf,
file: PathBuf,
png_file: PathBuf,
old: lightcraft_pipeline::tone::CameraTone,
new: lightcraft_pipeline::tone::CameraTone,
calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
plain_loader: crate::media::FileLoader,
}
impl SmartLookCase {
fn new(format: &str) -> Self {
let src = temp_dir(&format!("smartlook-src-{format}"));
let lib = temp_dir(&format!("smartlook-lib-{format}"));
write_png(&src.join("a.png"), 3);
write_png(&src.join("b.png"), 9);
let mut session = Session::new().with_fs();
session.open_library(&lib, false).unwrap();
session.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
let (id, png_id) = (find(&session, "a.png"), find(&session, "b.png"));
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
let mut raw = (**session.catalog.photo(id).unwrap()).clone();
raw.format = format.into();
raw.kind = lightcraft_catalog::MediaKind::Raw;
session.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
assert_eq!(session.catalog.photo(id).unwrap().relative_wb(), format == "ARW");
assert!(!session.catalog.photo(png_id).unwrap().relative_wb());
session.execute("library.select", &json!({"ids": [id.0, png_id.0], "active": id.0})).unwrap();
let dir = session.media.smart_dir.clone().unwrap();
let file = crate::smart::sharded_file_path(&dir, session.catalog.photo(id).unwrap());
let png_file = crate::smart::sharded_file_path(&dir, session.catalog.photo(png_id).unwrap());
let (old, new) = (test_tone(1.5), test_tone(3.5));
let calls = std::sync::Arc::new(AtomicUsize::new(0));
let plain_loader = session.media.file_loader.clone().unwrap();
session.media.file_loader = Some(look_loader(plain_loader.clone(), old, calls.clone()));
Self { src, lib, session, id, dir, file, png_file, old, new, calls, plain_loader }
}
}
fn assert_current_smart_look_is_not_rebuilt(case: &mut SmartLookCase) {
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}");
assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version");
assert!(!crate::smart::is_stale(&file));
assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version");
assert!(!crate::smart::is_stale(&case.file));
// current header: kept as it is, the original is not even read
let before = std::fs::read(&file).unwrap();
calls.store(0, Ordering::SeqCst);
s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone()));
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
let before = std::fs::read(&case.file).unwrap();
case.calls.store(0, std::sync::atomic::Ordering::SeqCst);
case.session.media.file_loader = Some(look_loader(case.plain_loader.clone(), case.new, case.calls.clone()));
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}");
assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt");
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(std::fs::read(&case.file).unwrap(), before, "a current proxy is not rebuilt");
assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
fn keep_offline_previews(case: &mut SmartLookCase) -> (Vec<u8>, Vec<u8>) {
// old headers (no field), originals offline: kept unchanged, and still marked stale
for f in [&file, &png_file] {
for f in [&case.file, &case.png_file] {
edit_smart_header(f, |h| {
h.as_object_mut().unwrap().remove("look_version");
});
}
assert!(crate::smart::is_stale(&file), "no field = version 0");
let old_bytes = std::fs::read(&file).unwrap();
let png_bytes = std::fs::read(&png_file).unwrap();
std::fs::rename(&src, src.with_extension("offline")).unwrap();
s.media.forget(id);
s.media.forget(png);
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert!(crate::smart::is_stale(&case.file), "no field = version 0");
let old_bytes = std::fs::read(&case.file).unwrap();
let png_bytes = std::fs::read(&case.png_file).unwrap();
std::fs::rename(&case.src, case.src.with_extension("offline")).unwrap();
case.session.media.forget(case.id);
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}");
assert!(r["failed"].as_array().unwrap().is_empty(), "{r}");
assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched");
assert!(crate::smart::is_stale(&file), "and it stays marked for the next time");
assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there");
assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes, "offline: the stored preview and curve are untouched");
assert!(crate::smart::is_stale(&case.file), "and it stays marked for the next time");
assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(case.old), "the old look is still there");
// ... and the opening refresh keeps it too, then marks the folder checked
assert!(!crate::smart::look_checked(&dir));
crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap();
assert!(std::fs::read(&file).unwrap() == old_bytes);
assert!(crate::smart::look_checked(&dir));
assert!(!crate::smart::look_checked(&case.dir));
crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap();
assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes);
assert!(crate::smart::look_checked(&case.dir));
(old_bytes, png_bytes)
}
fn refresh_online_previews(case: &mut SmartLookCase, old_bytes: &[u8], png_bytes: &[u8]) {
// the original comes back: the scan has run at this version, so the next opening leaves it;
// Build Smart Previews rebuilds it with the current look
std::fs::rename(src.with_extension("offline"), &src).unwrap();
calls.store(0, Ordering::SeqCst);
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version");
assert!(std::fs::read(&file).unwrap() == old_bytes);
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}");
assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION));
assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look");
assert!(!crate::smart::is_stale(&file));
assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look");
std::fs::rename(case.src.with_extension("offline"), &case.src).unwrap();
case.calls.store(0, std::sync::atomic::Ordering::SeqCst);
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none(), "checked once per version");
assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes);
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(1), 1), "{r}");
assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION));
assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, Some(case.new), "rebuilt with the current look");
assert!(!crate::smart::is_stale(&case.file));
assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes, "a PNG's proxy is never rebuilt for the look");
// a folder checked at an older version (or not at all) is scanned once at the next opening
edit_smart_header(&file, |h| h["look_version"] = json!(0));
std::fs::write(dir.join("look-version"), b"0\n").unwrap();
assert!(!crate::smart::look_checked(&dir));
s.media.forget(id);
calls.store(0, Ordering::SeqCst);
crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read");
assert!(!crate::smart::is_stale(&file));
assert!(std::fs::read(&png_file).unwrap() == png_bytes);
assert!(crate::smart::look_checked(&dir));
edit_smart_header(&case.file, |h| h["look_version"] = json!(0));
std::fs::write(case.dir.join("look-version"), b"0\n").unwrap();
assert!(!crate::smart::look_checked(&case.dir));
case.session.media.forget(case.id);
case.calls.store(0, std::sync::atomic::Ordering::SeqCst);
crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap();
assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 1, "only the raw's original is read");
assert!(!crate::smart::is_stale(&case.file));
assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes);
assert!(crate::smart::look_checked(&case.dir));
// once only
let rebuilt = std::fs::read(&file).unwrap();
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none());
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}");
assert!(std::fs::read(&file).unwrap() == rebuilt);
let rebuilt = std::fs::read(&case.file).unwrap();
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none());
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(0), 1), "{r}");
assert_eq!(std::fs::read(&case.file).unwrap(), rebuilt);
// Build Smart Previews refreshes a stale one the same way
edit_smart_header(&file, |h| h["look_version"] = json!(0));
s.media.file_loader = Some(plain);
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
edit_smart_header(&case.file, |h| h["look_version"] = json!(0));
case.session.media.file_loader = Some(case.plain_loader.clone());
let r = case.session.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!(r["refreshed"], 1, "{r}");
assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept");
}
/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file
+19 -8
View File
@@ -19,6 +19,14 @@ pub struct Rgb<'a> {
pub height: usize,
}
/// Face bounds, padding factor, and output dimensions for box-based alignment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceCrop {
pub bounds: (f32, f32, f32, f32),
pub padding: f32,
pub output_size: (usize, usize),
}
impl Rgb<'_> {
fn valid(&self) -> bool {
self.width > 0 && self.height > 0 && self.width.checked_mul(self.height).and_then(|n| n.checked_mul(3)) == Some(self.data.len())
@@ -145,7 +153,10 @@ pub fn align_to_template(img: &Rgb, landmarks: &[(f32, f32); 5], out_w: usize, o
}
/// A square around a face box (pixels of `img`), `pad` times its larger side, as `out_w` × `out_h`.
pub fn crop_box(img: &Rgb, x0: f32, y0: f32, x1: f32, y1: f32, pad: f32, out_w: usize, out_h: usize) -> Option<Vec<u8>> {
pub fn crop_box(img: &Rgb, crop: FaceCrop) -> Option<Vec<u8>> {
let (x0, y0, x1, y1) = crop.bounds;
let pad = crop.padding;
let (out_w, out_h) = crop.output_size;
if ![x0, y0, x1, y1, pad].iter().all(|v| v.is_finite()) {
return None;
}
@@ -247,16 +258,16 @@ mod tests {
}
}
let img = Rgb { data: &data, width: w, height: h };
let out = crop_box(&img, 90.0, 40.0, 110.0, 60.0, 2.0, 64, 64).unwrap();
let out = crop_box(&img, FaceCrop { bounds: (90.0, 40.0, 110.0, 60.0), padding: 2.0, output_size: (64, 64) }).unwrap();
// the 20 px block fills the middle half of the 64 px output
let at = |x: usize, y: usize| out[(y * 64 + x) * 3 + 1];
assert!(at(32, 32) > 250 && at(20, 20) > 250 && at(44, 44) > 250 && at(5, 5) < 5 && at(60, 32) < 5);
assert!(crop_box(&img, 5.0, 5.0, 5.0, 5.0, 2.0, 64, 64).is_none(), "an empty box");
assert!(crop_box(&img, f32::NAN, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none());
assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 0, 64).is_none());
assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 100_000, 64).is_none());
assert!(crop_box(&img, FaceCrop { bounds: (5.0, 5.0, 5.0, 5.0), padding: 2.0, output_size: (64, 64) }).is_none(), "an empty box");
assert!(crop_box(&img, FaceCrop { bounds: (f32::NAN, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none());
assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (0, 64) }).is_none());
assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (100_000, 64) }).is_none());
let bad = Rgb { data: &data[..10], width: w, height: h };
assert!(crop_box(&bad, 0.0, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none(), "wrong data length");
assert!(crop_box(&bad, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none(), "wrong data length");
}
#[test]
@@ -264,7 +275,7 @@ mod tests {
let data = vec![80u8; 50 * 50 * 3];
let img = Rgb { data: &data, width: 50, height: 50 };
// a box mostly outside the picture is padded by clamping, not an error
assert!(crop_box(&img, -30.0, -30.0, 20.0, 20.0, 1.5, 32, 32).is_some());
assert!(crop_box(&img, FaceCrop { bounds: (-30.0, -30.0, 20.0, 20.0), padding: 1.5, output_size: (32, 32) }).is_some());
let far: [(f32, f32); 5] = [(1e6, 1e6), (1e6 + 5.0, 1e6), (1e6 + 2.0, 1e6 + 3.0), (1e6, 1e6 + 6.0), (1e6 + 5.0, 1e6 + 6.0)];
let _ = align_to_template(&img, &far, 112, 112);
let tiny: [(f32, f32); 5] = [(1.0, 1.0), (1.01, 1.0), (1.0, 1.01), (1.01, 1.01), (1.005, 1.005)];
+12 -8
View File
@@ -1,5 +1,5 @@
//! Safe CPU kernels shared by detection and recognition. No inner thread pool.
use super::*;
use super::{ConvJob, Graph, MAX_TENSOR, NetError, Node, Result, Tensor, chw, conv_direct, pads, pair, program, shape, shape_err};
use ndarray::{ArrayView2, ArrayViewMut2, linalg::general_mat_mul};
const BLOCK: usize = 256;
#[derive(Default)]
@@ -30,7 +30,16 @@ fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -
}
/// For equal-width, unit-stride maps a shifted plane is one contiguous copy. Only horizontal
/// border stripes need masking; clipping the flat source range also removes vertical padding.
fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, pl: usize, start: usize, row: &mut [f32]) -> Result<()> {
#[derive(Clone, Copy)]
struct PlaneShift {
width: usize,
kernel: (usize, usize),
padding: (usize, usize),
start: usize,
}
fn shifted_plane(plane: &[f32], row: &mut [f32], shift: PlaneShift) -> Result<()> {
let PlaneShift { width, kernel: (ky, kx), padding: (pt, pl), start } = shift;
let end = start + row.len();
let delta = (ky as i128 - pt as i128) * width as i128 + kx as i128 - pl as i128;
let lo = (-delta).clamp(start as i128, end as i128);
@@ -145,13 +154,8 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re
if sh == 1 && sw == 1 && ow == w {
shifted_plane(
plane,
w,
ky,
kx,
pt,
pl,
start,
row.get_mut(..len).ok_or_else(|| NetError::Shape("short lower panel".into()))?,
PlaneShift { width: w, kernel: (ky, kx), padding: (pt, pl), start },
)?;
continue;
}
+1 -1
View File
@@ -9,7 +9,7 @@
use std::borrow::Cow;
use super::*;
use super::{ConvJob, Data, HashMap, Net, NetError, Result, kernels, pads, pair, shape, shape_err, unsupported};
/// Value 0 is the network's input.
pub const INPUT: usize = 0;
+1 -1
View File
@@ -1,5 +1,5 @@
//! Bounded, allocation-free (apart from small shape lists) graph checking.
use super::*;
use super::{Data, Graph, HashMap, MAX_TENSOR, NetError, Node, Result, pads, pair, shape_err, unsupported};
use crate::graph::{Attr, Weight};
pub(super) fn elements(s: &[usize]) -> Result<usize> {
+17 -14
View File
@@ -235,32 +235,35 @@ fn round_up(v: usize, to: usize) -> usize {
v.div_ceil(to) * to
}
/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells.
fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec<Layer>), String> {
fn validate_plan(net: &Net, tile: usize, ops: &[Op]) -> Result<(), String> {
if !net.fits_tile(tile) {
return unsupported("the tile does not fit the network");
}
if tile > MAX_TILE {
return unsupported("the tile is larger than the kernels' dispatches reach");
}
if !net.in_channels.is_multiple_of(4) {
return unsupported("the input channel count is not a multiple of 4");
}
let count = ops.len();
let ends_right = matches!(
(count.checked_sub(2).and_then(|i| ops.get(i)), ops.last()),
(Some(Op::Conv(conv)), Some(Op::DepthToSpace2 { src }))
if conv.k == 1 && conv.src2.is_none() && *src == count - 1 && conv.cout == 12 && conv.leaky.is_none()
);
if ends_right { Ok(()) } else { unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space") }
}
/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells.
fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec<Layer>), String> {
let ops = net.ops();
validate_plan(net, tile, ops)?;
let side = |t: usize| -> usize {
let level = net.shape(t).map_or(0, |s| s.level);
if level >= 0 { tile >> level } else { tile << (-level) }
};
let chan = |t: usize| net.shape(t).map_or(0, |s| s.channels);
if !net.in_channels.is_multiple_of(4) {
return unsupported("the input channel count is not a multiple of 4");
}
let ops = net.ops();
// the network must end in a 1 × 1 convolution and a depth-to-space (they run as one kernel)
let n_ops = ops.len();
let ends_right = matches!(
(n_ops.checked_sub(2).and_then(|i| ops.get(i)), ops.last()),
(Some(Op::Conv(c)), Some(Op::DepthToSpace2 { src })) if c.k == 1 && c.src2.is_none() && *src == n_ops - 1 && c.cout == 12 && c.leaky.is_none()
);
if !ends_right {
return unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space");
}
// the last layer that reads each tensor
let mut last_use = vec![0usize; n_ops + 1];
for (i, op) in ops.iter().enumerate() {
+10 -2
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use lightcraft_develop::DevelopSettings;
use lightcraft_geom::Orientation;
use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms};
use lightcraft_pipeline::finish::{FinishParams, FinishParamsInput, MASK_TERMS, mask_terms};
use lightcraft_pipeline::geometry::SampleMode;
use lightcraft_pipeline::local::plane_at;
use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local};
@@ -668,7 +668,15 @@ pub fn render(
lap("masks", &mut t, &mut cx);
// 5. per-pixel stage
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
let mut fp = FinishParams::new(FinishParamsInput {
settings: s,
frame: &plan.frame,
info,
image_size: (w, h),
px_per_long: plan.px_per_long,
air_pre: prep.air,
space: req.space,
});
// soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`)
fp.proof = req.proof.map(|pr| pr.params(req.space));
if let Some(d) = &req.display {
+9
View File
@@ -201,6 +201,11 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u3
fn parse_sps(rbsp: &[u8]) -> Option<Signal> {
let r = &mut Bits { data: rbsp, pos: 0 };
let (_, _, max_sub_layers_minus1) = sps_size(r)?;
parse_sps_parameters(r, max_sub_layers_minus1)?;
parse_vui(r)
}
fn parse_sps_parameters(r: &mut Bits<'_>, max_sub_layers_minus1: u32) -> Option<()> {
if r.flag()? {
for _ in 0..4 {
r.ue()?; // conf_win_*_offset
@@ -253,6 +258,10 @@ fn parse_sps(rbsp: &[u8]) -> Option<Signal> {
}
}
r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag
Some(())
}
fn parse_vui(r: &mut Bits<'_>) -> Option<Signal> {
let mut signal = Signal::default();
if !r.flag()? {
return Some(signal); // no VUI
+62 -37
View File
@@ -246,33 +246,13 @@ impl Backend for HoldAfterFirst {
}
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
/// the cancel is read, so neither can finish first on a fast machine.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let hold = Arc::new(AtomicBool::new(true));
let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() };
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
if tx.send(value).is_err() {
break;
}
}
});
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next();
let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap();
type Rx = std::sync::mpsc::Receiver<Value>;
fn next_msg(rx: &Rx) -> Value {
rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress")
}
fn assert_query_photos(photos: &[Value]) {
assert_eq!(photos.len(), 8);
// Real files, real metadata: eight distinct 1200×800 PNGs probed from disk, not synthesized.
let names: std::collections::HashSet<&str> = photos.iter().map(|p| p["fileName"].as_str().unwrap()).collect();
@@ -281,13 +261,15 @@ fn export_progress_and_cancel() {
assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1200), Some(800)), "{p}");
assert_eq!(p["format"], "PNG", "{p}");
}
let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect();
}
fn run_complete_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], dir: &std::path::Path) {
let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"}));
request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"});
writeln!(send, "{request}").unwrap();
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
loop {
let value = next();
let value = next_msg(rx);
if value["method"] == "notifications/progress" {
assert_eq!(value["params"]["progressToken"], "photos");
let progress = value["params"]["progress"].as_f64().unwrap();
@@ -312,17 +294,16 @@ fn export_progress_and_cancel() {
}
assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}");
assert_eq!(last, 8.0);
let out = dir.join("cancelled");
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
}
fn run_cancelled_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], out: &std::path::Path) {
// this export waits after its first photo until the cancel below is read
hold.store(true, Ordering::SeqCst);
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
request["params"]["_meta"] = json!({"progressToken":44});
writeln!(send, "{request}").unwrap();
loop {
let value = next();
let value = next_msg(rx);
assert_ne!(value["id"], 4, "must be cancellable before completion: {value}");
assert_eq!(value["params"]["progressToken"], 44, "{value}");
if value["params"]["progress"].as_f64().unwrap() > 0.0 {
@@ -332,7 +313,7 @@ fn export_progress_and_cancel() {
writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap();
writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap();
loop {
let value = next();
let value = next_msg(rx);
if value["method"] == "notifications/progress" {
continue;
}
@@ -340,13 +321,19 @@ fn export_progress_and_cancel() {
assert_eq!(value["result"]["isError"], false);
break;
}
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
}
fn assert_cancelled_output(out: &std::path::Path, decoy: &std::path::Path) {
assert_eq!(std::fs::read(decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(out).unwrap().map(|e| e.unwrap().path()).filter(|p| p != decoy).collect();
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
for path in &photos {
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
assert_eq!((d.width, d.height), (1200, 800), "{}", path.display());
}
}
fn assert_complete_output(dir: &std::path::Path) {
// The finished export's eight outputs are real 1200×800 PNGs, one per distinct original.
let complete: Vec<_> = std::fs::read_dir(dir.join("complete")).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(complete.len(), 8, "earlier export retained: {complete:?}");
@@ -359,6 +346,44 @@ fn export_progress_and_cancel() {
exported.insert(bytes);
}
assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export");
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
/// the cancel is read, so neither can finish first on a fast machine.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let hold = Arc::new(AtomicBool::new(true));
let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() };
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
if tx.send(value).is_err() {
break;
}
}
});
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next_msg(&rx);
let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap();
assert_query_photos(photos);
let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect();
run_complete_export(&mut send, &rx, &hold, &ids, &dir);
let out = dir.join("cancelled");
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
run_cancelled_export(&mut send, &rx, &hold, &ids, &out);
assert_cancelled_output(&out, &decoy);
assert_complete_output(&dir);
drop(send);
// The fixture's files stay in place until the server (which owns the session) and the reader
// have finished: the session reads the originals; the output is what the checks above read.
+23 -3
View File
@@ -187,7 +187,16 @@ impl Metadata {
orientation,
gps,
width,
height,
height
);
self.fill_missing_descriptive(other);
}
/// Fill descriptive metadata separately from the camera and image properties.
fn fill_missing_descriptive(&mut self, other: &Metadata) {
fill!(
self,
other,
artist,
copyright,
copyright_marked,
@@ -405,11 +414,22 @@ mod tests {
#[test]
fn merge_rules() {
let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() };
let b = Metadata { make: Some("B".into()), model: Some("M".into()), rating: Some(4), keywords: vec!["k".into()], ..Default::default() };
let mut a = Metadata { make: Some("A".into()), title: Some("Original".into()), rating: Some(1), ..Default::default() };
let b = Metadata {
make: Some("B".into()),
model: Some("M".into()),
title: Some("Replacement".into()),
caption: Some("Caption".into()),
rating: Some(4),
keywords: vec!["k".into()],
..Default::default()
};
a.fill_missing(&b);
assert_eq!(a.make.as_deref(), Some("A"));
assert_eq!(a.model.as_deref(), Some("M"));
assert_eq!(a.title.as_deref(), Some("Original"));
assert_eq!(a.caption.as_deref(), Some("Caption"));
assert_eq!(a.keywords, vec!["k".to_string()]);
assert_eq!(a.rating, Some(1));
a.overlay_user_fields(&b);
assert_eq!(a.rating, Some(4));
+156 -159
View File
@@ -186,19 +186,20 @@ pub struct FinishParams {
pub peak: f32,
}
pub struct FinishParamsInput<'a> {
pub settings: &'a DevelopSettings,
pub frame: &'a Frame,
pub info: &'a SourceInfo,
pub image_size: (usize, usize),
pub px_per_long: f64,
pub air_pre: f32,
pub space: OutputSpace,
}
impl FinishParams {
/// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`].
#[allow(clippy::too_many_arguments)]
pub fn new(
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
w: usize,
h: usize,
px_per_long: f64,
air_pre: f32,
space: OutputSpace,
) -> FinishParams {
pub fn new(input: FinishParamsInput<'_>) -> FinishParams {
let FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long, air_pre, space } = input;
let effects = s.section_enabled("effects");
let (clar, tex, dehaze) = if effects {
((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32)
@@ -288,7 +289,8 @@ pub(crate) fn finish(
proof: Option<crate::Proof>,
) -> Rgba8 {
let (w, h) = (p.img.width, p.img.height);
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let mut fp =
FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space });
fp.proof = proof.map(|pr| pr.params(space));
if let Some(d) = display {
fp.for_display(d, proof);
@@ -305,7 +307,6 @@ pub(crate) fn finish(
}
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
#[allow(clippy::too_many_arguments)]
pub(crate) fn finish_deep(
p: &Prepared,
s: &DevelopSettings,
@@ -317,7 +318,8 @@ pub(crate) fn finish_deep(
) -> DeepImage {
use lightcraft_color::transfer::srgb_to_linear;
let (w, h) = (p.img.width, p.img.height);
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let mut fp =
FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space });
fp.proof = proof.map(|pr| pr.params(space));
let trc = fp.out_trc;
let samples = match depth {
@@ -354,29 +356,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
) -> Vec<T> {
let (w, h) = (p.img.width, p.img.height);
let p_lut = fp.lut.clone();
let FinishParams {
tone,
ops,
curves,
vig,
to_out,
out_luma,
hl,
sh,
clar,
tex,
dehaze,
sharpen,
sharpen_mask,
air,
air_pre,
gain,
ev,
grain,
..
} = fp;
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
let FinishParams { curves, vig, to_out, out_luma, grain, .. } = fp;
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
let out_to_norm = fp.out_to_norm;
let long = fp.ow.max(fp.oh);
@@ -392,128 +372,17 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
for (x, px) in row.iter_mut().enumerate() {
let i = y * w + x;
let raw = p.img.data[i];
let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) };
let local = pixel_local_terms(p, &terms, i);
let l_sat = local.values[11];
let l_hue = local.values[12];
let l_pre = p.log_l.data[i];
let l0 = l_pre + ev;
// --- local (mask) contributions: alpha-weighted sums of the masks' terms
let mut l = [0.0f32; MASK_SUMS];
let mut tint_col: Option<([f32; 3], f32)> = None;
for (m, t) in p.masks.iter().zip(&terms) {
let a = m.alpha.data[i];
if a <= 0.0 {
continue;
}
for k in 0..MASK_SUMS {
l[k] += a * t[k];
}
if t[MASK_SUMS] > 0.0 {
tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3]));
}
}
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] =
l;
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
if mt != 0.0
&& let Some(cb) = &p.chroma_blur
{
let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]);
c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0));
}
// --- local Defringe: desaturate purple / green fringes along edges
let df = l_defringe.clamp(0.0, 1.0);
if df > 0.0
&& let Some(b) = &p.texture_blur
{
let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]);
if k > 0.0 {
let y = luminance_2020(c);
c = c.map(|v| v + (y - v) * k);
}
}
// --- dehaze (scene linear)
let dz = dehaze + l_dehaze;
if dz != 0.0
&& let Some(dark) = &p.dark
{
// The dark channel is a blur, not a patch minimum: near bright neighbours it credits
// dark pixels with veil they don't hold (and a windowed render borrows the whole
// frame's airlight), so the subtraction went past zero and crushed them to black
// (#2504). Never attribute more veil than the pixel's own minimum channel.
let c_min = c[0].min(c[1]).min(c[2]);
let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0);
if dz > 0.0 {
let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12);
c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0));
} else {
let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d);
c = c.map(|v| v + (air * 0.9 - v) * k);
}
}
// --- local exposure / temp / tint
if l_exp != 0.0 {
let g = l_exp.exp2();
c = c.map(|v| v * g);
}
if l_temp != 0.0 || l_tint != 0.0 {
let y0 = luminance_2020(c);
c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)];
let y1 = luminance_2020(c).max(1e-9);
c = c.map(|v| v * y0 / y1);
}
let l0 = l_pre + fp.ev;
let (mut c, l1) = adjust_scene_pixel(p, fp, i, raw, &local);
// --- local tone in log luminance
let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 };
let shift = l1 - l0;
let base = p.base.data[i] + ev + shift;
let mut delta = 0.0f32;
let (hh, ss) = (hl + l_hl, sh + l_sh);
if hh != 0.0 || ss != 0.0 {
let ws = 1.0 - smooth(-4.8, 0.3, base);
let wh = smooth(-1.0, 2.8, base);
delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh;
}
if l_wh != 0.0 {
delta += l_wh * 0.8 * smooth(0.5, 3.0, l1);
}
if l_bl != 0.0 {
delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1));
}
if l_con != 0.0 {
delta += l_con * 0.14 * l1.clamp(-6.0, 4.0);
}
let cl = clar + l_clar;
if cl != 0.0
&& let Some(b) = &p.clarity_blur
{
let det = (l_pre - b.data[i]).clamp(-2.5, 2.5);
let mid = (-(base / 3.2).powi(2)).exp();
delta += cl * 0.85 * det * (0.35 + 0.65 * mid);
}
let tx = tex + l_tex;
let sp = l_sharp * 0.6 + sharpen;
if (tx != 0.0 || sp != 0.0)
&& let Some(b) = &p.texture_blur
{
let det = l_pre - b.data[i];
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs());
delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame;
if sp != 0.0 {
let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 };
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if l_noise != 0.0
&& let Some(b) = &p.texture_blur
{
let det = l_pre - b.data[i];
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs()));
}
let base = p.base.data[i] + fp.ev + shift;
let delta = local_tone_delta(p, fp, i, base, l1, &local);
if delta != 0.0 {
let g = delta.exp2();
c = c.map(|v| v * g);
@@ -526,9 +395,9 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
// --- tone map on luminance, highlight desaturation
let yl = luminance_2020(c);
let o = tone.apply(yl);
let o = fp.tone.apply(yl);
let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] };
let k = tone.chroma_scale(o);
let k = fp.tone.chroma_scale(o);
if k != 1.0 {
d = d.map(|v| o + (v - o) * k);
}
@@ -539,8 +408,8 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
}
// --- colour
d = ops.apply(d, l_sat, l_hue);
if let Some((dir, amt)) = tint_col {
d = fp.ops.apply(d, l_sat, l_hue);
if let Some((dir, amt)) = local.tint {
let lab = lightcraft_color::perceptual::oklab_from_2020(d);
d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]);
}
@@ -663,6 +532,134 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
out
}
struct PixelLocalTerms {
values: [f32; MASK_SUMS],
tint: Option<([f32; 3], f32)>,
}
fn pixel_local_terms(p: &Prepared, terms: &[[f32; MASK_TERMS]], index: usize) -> PixelLocalTerms {
let mut values = [0.0; MASK_SUMS];
let mut tint = None;
for (mask, terms) in p.masks.iter().zip(terms) {
let alpha = mask.alpha.data[index];
if alpha <= 0.0 {
continue;
}
for (sum, term) in values.iter_mut().zip(terms.iter().take(MASK_SUMS)) {
*sum += alpha * term;
}
if terms[MASK_SUMS] > 0.0 {
tint = Some(([terms[MASK_SUMS + 1], terms[MASK_SUMS + 2], 0.0], alpha * terms[MASK_SUMS + 3]));
}
}
PixelLocalTerms { values, tint }
}
fn adjust_scene_pixel(p: &Prepared, fp: &FinishParams, index: usize, raw: [f32; 3], local: &PixelLocalTerms) -> ([f32; 3], f32) {
let [l_exp, l_temp, l_tint, _, _, _, _, _, _, _, l_dehaze, _, _, _, l_noise, l_moire, l_defringe] = local.values;
let mut color = if fp.gain == 1.0 { raw } else { raw.map(|value| value * fp.gain) };
let moire = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
if moire != 0.0
&& let Some(blur) = &p.chroma_blur
{
let (luminance, original, blurred) = (luminance_2020(color), crate::masks::chromaticity(raw), blur.data[index]);
color = std::array::from_fn(|channel| ((original[channel] + (blurred[channel] - original[channel]) * moire) * luminance).max(0.0));
}
let defringe = l_defringe.clamp(0.0, 1.0);
if defringe > 0.0
&& let Some(blur) = &p.texture_blur
{
let amount = defringe * defringe_weight(color, p.log_l.data[index] - blur.data[index]);
if amount > 0.0 {
let luminance = luminance_2020(color);
color = color.map(|value| value + (luminance - value) * amount);
}
}
let dehaze = fp.dehaze + l_dehaze;
if dehaze != 0.0
&& let Some(dark) = &p.dark
{
// Limit the estimated veil to the pixel's own minimum channel. This avoids crushing bright
// neighbours when the dark-channel blur spans the edge of a local window.
let min_channel = color[0].min(color[1]).min(color[2]);
let veil = (if fp.air_pre > 0.0 { dark.data[index] / fp.air_pre } else { 0.0 })
.min(if fp.air > 0.0 { min_channel / fp.air } else { 0.0 })
.clamp(0.0, 1.0);
if dehaze > 0.0 {
let transmission = (1.0 - 0.95 * dehaze.min(1.0) * veil).max(0.12);
color = color.map(|value| ((value - fp.air * (1.0 - transmission)) / transmission).max(0.0));
} else {
let amount = (-dehaze).min(1.0) * 0.7 * (0.35 + 0.65 * veil);
color = color.map(|value| value + (fp.air * 0.9 - value) * amount);
}
}
if l_exp != 0.0 {
let gain = l_exp.exp2();
color = color.map(|value| value * gain);
}
if l_temp != 0.0 || l_tint != 0.0 {
let luminance_before = luminance_2020(color);
color = [color[0] * (1.0 + 0.3 * l_temp), color[1] * (1.0 - 0.22 * l_tint), color[2] * (1.0 - 0.3 * l_temp).max(0.0)];
let luminance_after = luminance_2020(color).max(1e-9);
color = color.map(|value| value * luminance_before / luminance_after);
}
let l0 = p.log_l.data[index] + fp.ev;
let log_luminance = if dehaze != 0.0 || l_exp != 0.0 { log_lum(color) } else { l0 };
(color, log_luminance)
}
fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1: f32, local: &PixelLocalTerms) -> f32 {
let [_, _, _, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, _, _, _, l_sharp, l_noise, _, _] = local.values;
let mut delta = 0.0;
let (highlights, shadows) = (fp.hl + l_hl, fp.sh + l_sh);
if highlights != 0.0 || shadows != 0.0 {
let shadow_weight = 1.0 - smooth(-4.8, 0.3, base);
let highlight_weight = smooth(-1.0, 2.8, base);
delta += shadows * 1.7 * shadow_weight * shadow_weight.sqrt() + highlights * 1.7 * highlight_weight;
}
if l_wh != 0.0 {
delta += l_wh * 0.8 * smooth(0.5, 3.0, l1);
}
if l_bl != 0.0 {
delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1));
}
if l_con != 0.0 {
delta += l_con * 0.14 * l1.clamp(-6.0, 4.0);
}
let clarity = fp.clar + l_clar;
if clarity != 0.0
&& let Some(blur) = &p.clarity_blur
{
let detail = (p.log_l.data[index] - blur.data[index]).clamp(-2.5, 2.5);
let midtone = (-(base / 3.2).powi(2)).exp();
delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone);
}
let texture = fp.tex + l_tex;
let sharpen = l_sharp * 0.6 + fp.sharpen;
if (texture != 0.0 || sharpen != 0.0)
&& let Some(blur) = &p.texture_blur
{
let detail = p.log_l.data[index] - blur.data[index];
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs());
delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame;
if sharpen != 0.0 {
let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 };
delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask;
}
}
if l_noise != 0.0
&& let Some(blur) = &p.texture_blur
{
let detail = p.log_l.data[index] - blur.data[index];
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * detail * (1.0 - smooth(0.1, 0.5, detail.abs()));
}
delta
}
/// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for
/// the gamut warnings (tolerates rounding at the gamut boundary).
const GAMUT_WARN: f32 = 0.995;
+50 -42
View File
@@ -284,48 +284,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
if let Some(v) = c.baseline_sharpness {
ifd.set(t::BASELINE_SHARPNESS, rat_vec(&[v]));
}
// the source's own profile look travels with its data (conversions, smart previews, merges)
let p = &c.profile;
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
if table.srgb_value {
ifd.set(enc, Value::Long(vec![1]));
}
};
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
match &p.hue_sat_map {
[Some(a), b] => {
table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a));
if let Some(b) =
b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions))
{
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b));
}
}
[None, Some(b)] => {
table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b));
}
[None, None] => {}
}
if let Some(l) = &p.look_table {
table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l));
}
if let Some(curve) = &p.tone_curve {
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
}
if let Some(map) = &p.gain_table_map {
// the raw IFD is IFD 0 here, where both tag versions are valid
let (version2, bytes) = map.to_bytes(opts.order);
ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes));
}
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
if !list.is_empty() {
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
}
}
write_profile_tags(&mut ifd, raw, opts.order);
write_opcode_tags(&mut ifd, raw);
match (&raw.data, opts.compression) {
(RawData::F32(v), DngCompression::Deflate { tile, half }) => {
@@ -495,6 +455,54 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
Ok(TiffWriter::new(opts.order, false).write(&[ifd])?)
}
fn write_profile_tags(ifd: &mut IfdBuilder, raw: &RawImage, order: ByteOrder) {
// The source's own profile look travels with its data (conversions, smart previews, merges).
let profile = &raw.color.profile;
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
if table.srgb_value {
ifd.set(enc, Value::Long(vec![1]));
}
};
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
match &profile.hue_sat_map {
[Some(first), second] => {
table_tags(ifd, first, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(first));
if let Some(second) = second.as_ref().filter(|second| {
(second.hue_divisions, second.sat_divisions, second.val_divisions) == (first.hue_divisions, first.sat_divisions, first.val_divisions)
}) {
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(second));
}
}
[None, Some(table)] => {
table_tags(ifd, table, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(table));
}
[None, None] => {}
}
if let Some(table) = &profile.look_table {
table_tags(ifd, table, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(table));
}
if let Some(curve) = &profile.tone_curve {
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
}
if let Some(map) = &profile.gain_table_map {
// The raw IFD is IFD 0 here, where both tag versions are valid.
let (version2, bytes) = map.to_bytes(order);
ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes));
}
}
fn write_opcode_tags(ifd: &mut IfdBuilder, raw: &RawImage) {
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
if !list.is_empty() {
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
}
}
}
/// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23).
fn illuminant_of(white: lightcraft_color::Xy) -> u16 {
if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 }
+140 -103
View File
@@ -253,6 +253,9 @@ impl Rd<'_> {
self.p += 8;
Some(v)
}
fn finite_f64(&mut self) -> Option<f64> {
self.f64().filter(|v| v.is_finite())
}
fn f32(&mut self) -> Option<f32> {
let v = f32::from_be_bytes(self.d.get(self.p..self.p + 4)?.try_into().ok()?);
self.p += 4;
@@ -276,109 +279,126 @@ impl Rd<'_> {
}
fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option<Opcode> {
let mut r = Rd { d: params, p: 0 };
let f = |r: &mut Rd| r.f64().filter(|v| v.is_finite());
Some(match id {
let mut reader = Rd { d: params, p: 0 };
let parsed = match id {
1 | 2 | 3 | 14 => parse_warp(id, flags, &mut reader)?,
4 => Opcode::FixBadPixelsConstant { constant: reader.u32()?, bayer_phase: reader.u32()? },
5 => parse_bad_pixel_list(&mut reader)?,
6 => Opcode::TrimBounds { top: reader.u32()?, left: reader.u32()?, bottom: reader.u32()?, right: reader.u32()? },
7..=13 => parse_area_opcode(id, &mut reader)?,
_ => Opcode::Unknown { id, flags, params: params.to_vec() },
};
Some(parsed)
}
fn parse_warp(id: u32, flags: u32, reader: &mut Rd<'_>) -> Option<Opcode> {
match id {
1 => {
let n = r.u32()? as usize;
if n == 0 || n > 4 {
return None;
}
let mut planes = Vec::with_capacity(n);
for _ in 0..n {
planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]);
}
Opcode::WarpRectilinear { planes, center: [f(&mut r)?, f(&mut r)?] }
let planes = parse_planes(reader, 4, |r| {
Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?])
})?;
Some(Opcode::WarpRectilinear { planes, center: [reader.finite_f64()?, reader.finite_f64()?] })
}
2 => {
let n = r.u32()? as usize;
if n == 0 || n > 4 {
return None;
}
let mut planes = Vec::with_capacity(n);
for _ in 0..n {
planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]);
}
Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] }
let planes = parse_planes(reader, 4, |r| Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]))?;
Some(Opcode::WarpFisheye { planes, center: [reader.finite_f64()?, reader.finite_f64()?] })
}
14 => {
let n = r.u32()? as usize;
// N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else
if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 {
return None;
}
let mut planes = Vec::with_capacity(n);
for _ in 0..n {
let mut k = [0.0; WR2_LEN];
for v in k.iter_mut() {
*v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?;
}
// the spec requires 0 <= min_valid_radius < max_valid_radius <= 1
if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) {
return None;
}
planes.push(k);
}
let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?];
Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 }
3 => Some(Opcode::FixVignetteRadial {
k: [reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?],
center: [reader.finite_f64()?, reader.finite_f64()?],
}),
14 => parse_warp_rectilinear2(flags, reader),
_ => None,
}
}
fn parse_planes<const N: usize>(reader: &mut Rd<'_>, max: usize, mut parse: impl FnMut(&mut Rd<'_>) -> Option<[f64; N]>) -> Option<Vec<[f64; N]>> {
let count = reader.u32()? as usize;
if count == 0 || count > max {
return None;
}
(0..count).map(|_| parse(reader)).collect()
}
fn parse_warp_rectilinear2(flags: u32, reader: &mut Rd<'_>) -> Option<Opcode> {
let count = reader.u32()? as usize;
// N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else.
if count == 0 || count > WR2_MAX_PLANES || reader.remaining() != count * WR2_LEN * 8 + 16 + 4 {
return None;
}
let mut planes = Vec::with_capacity(count);
for _ in 0..count {
let mut coefficients = [0.0; WR2_LEN];
for value in &mut coefficients {
*value = reader.finite_f64().filter(|value| value.abs() <= WR2_MAX_COEF)?;
}
3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] },
4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? },
5 => {
let bayer_phase = r.u32()?;
let (np, nr) = (r.u32()? as usize, r.u32()? as usize);
if np.saturating_mul(8).saturating_add(nr.saturating_mul(16)) > r.remaining() {
return None;
}
let points = (0..np).map(|_| Some((r.u32()?, r.u32()?))).collect::<Option<_>>()?;
let rects = (0..nr).map(|_| Some([r.u32()?, r.u32()?, r.u32()?, r.u32()?])).collect::<Option<_>>()?;
Opcode::FixBadPixelsList { bayer_phase, points, rects }
if !(coefficients[17] >= 0.0 && coefficients[17] < coefficients[18] && coefficients[18] <= 1.0) {
return None;
}
6 => Opcode::TrimBounds { top: r.u32()?, left: r.u32()?, bottom: r.u32()?, right: r.u32()? },
planes.push(coefficients);
}
let center = [reader.finite_f64().filter(|value| value.abs() <= 4.0)?, reader.finite_f64().filter(|value| value.abs() <= 4.0)?];
Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: reader.u32()? != 0, optional: flags & 1 != 0 })
}
fn parse_bad_pixel_list(reader: &mut Rd<'_>) -> Option<Opcode> {
let bayer_phase = reader.u32()?;
let (point_count, rect_count) = (reader.u32()? as usize, reader.u32()? as usize);
if point_count.saturating_mul(8).saturating_add(rect_count.saturating_mul(16)) > reader.remaining() {
return None;
}
let points = (0..point_count).map(|_| Some((reader.u32()?, reader.u32()?))).collect::<Option<_>>()?;
let rects = (0..rect_count).map(|_| Some([reader.u32()?, reader.u32()?, reader.u32()?, reader.u32()?])).collect::<Option<_>>()?;
Some(Opcode::FixBadPixelsList { bayer_phase, points, rects })
}
fn parse_area_opcode(id: u32, reader: &mut Rd<'_>) -> Option<Opcode> {
let area = reader.area()?;
match id {
7 => {
let area = r.area()?;
let n = r.u32()? as usize;
if n == 0 || n > 65536 || n * 2 > r.remaining() {
let count = reader.u32()? as usize;
if count == 0 || count > 65536 || count * 2 > reader.remaining() {
return None;
}
Opcode::MapTable { area, table: (0..n).map(|_| r.u16()).collect::<Option<_>>()? }
Some(Opcode::MapTable { area, table: (0..count).map(|_| reader.u16()).collect::<Option<_>>()? })
}
8 => {
let area = r.area()?;
let deg = r.u32()? as usize;
if deg > 8 {
let degree = reader.u32()? as usize;
if degree > 8 {
return None;
}
Opcode::MapPolynomial { area, coefficients: (0..=deg).map(|_| f(&mut r)).collect::<Option<_>>()? }
Some(Opcode::MapPolynomial { area, coefficients: (0..=degree).map(|_| reader.finite_f64()).collect::<Option<_>>()? })
}
9 => {
let area = r.area()?;
let (pv, ph) = (r.u32()?, r.u32()?);
let spacing = [f(&mut r)?, f(&mut r)?];
let origin = [f(&mut r)?, f(&mut r)?];
let mp = r.u32()?;
let n = (pv as usize).checked_mul(ph as usize)?.checked_mul(mp as usize)?;
if n == 0 || n * 4 > r.remaining() {
return None;
}
let gains = (0..n).map(|_| r.f32()).collect::<Option<_>>()?;
Opcode::GainMap { area, points_v: pv, points_h: ph, spacing, origin, map_planes: mp, gains }
}
10..=13 => {
let area = r.area()?;
let n = r.u32()? as usize;
if n * 4 > r.remaining() {
return None;
}
let v: Vec<f32> = (0..n).map(|_| r.f32()).collect::<Option<_>>()?;
match id {
10 => Opcode::DeltaPerRow { area, deltas: v },
11 => Opcode::DeltaPerColumn { area, deltas: v },
12 => Opcode::ScalePerRow { area, scales: v },
_ => Opcode::ScalePerColumn { area, scales: v },
}
}
_ => Opcode::Unknown { id, flags, params: params.to_vec() },
9 => parse_gain_map(area, reader),
10..=13 => parse_delta_scale(id, area, reader),
_ => None,
}
}
fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option<Opcode> {
let (points_v, points_h) = (reader.u32()?, reader.u32()?);
let spacing = [reader.finite_f64()?, reader.finite_f64()?];
let origin = [reader.finite_f64()?, reader.finite_f64()?];
let map_planes = reader.u32()?;
let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?;
if count == 0 || count > reader.remaining() / 4 {
return None;
}
let gains = (0..count).map(|_| reader.f32()).collect::<Option<_>>()?;
Some(Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains })
}
fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option<Opcode> {
let count = reader.u32()? as usize;
if count > reader.remaining() / 4 {
return None;
}
let values: Vec<f32> = (0..count).map(|_| reader.f32()).collect::<Option<_>>()?;
Some(match id {
10 => Opcode::DeltaPerRow { area, deltas: values },
11 => Opcode::DeltaPerColumn { area, deltas: values },
12 => Opcode::ScalePerRow { area, scales: values },
_ => Opcode::ScalePerColumn { area, scales: values },
})
}
@@ -508,19 +528,29 @@ fn area_iter(a: &Area, w: usize, h: usize, cpp: usize) -> impl Iterator<Item = (
.flat_map(move |y| (l..r.max(l)).step_by(a.col_pitch.max(1) as usize).flat_map(move |x| (p0..p1.max(p0)).map(move |p| (x, y, p))))
}
fn gain_at(points_v: u32, points_h: u32, spacing: [f64; 2], origin: [f64; 2], map_planes: u32, gains: &[f32], rv: f64, rh: f64, plane: usize) -> f32 {
let (pv, ph) = (points_v as usize, points_h as usize);
let mp = map_planes as usize;
let p = plane.min(mp - 1);
let fy = if spacing[0] > 0.0 { ((rv - origin[0]) / spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 };
let fx = if spacing[1] > 0.0 { ((rh - origin[1]) / spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 };
let (y0, x0) = (fy.floor() as usize, fx.floor() as usize);
let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1));
let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32);
let g = |y: usize, x: usize| gains[(y * ph + x) * mp + p];
let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx;
let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx;
top + (bot - top) * ty
struct GainMapGrid<'a> {
points: [usize; 2],
spacing: [f64; 2],
origin: [f64; 2],
planes: usize,
gains: &'a [f32],
}
impl GainMapGrid<'_> {
fn sample_at(&self, position: [f64; 2], plane: usize) -> f32 {
let [pv, ph] = self.points;
let mp = self.planes;
let p = plane.min(mp - 1);
let fy = if self.spacing[0] > 0.0 { ((position[0] - self.origin[0]) / self.spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 };
let fx = if self.spacing[1] > 0.0 { ((position[1] - self.origin[1]) / self.spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 };
let (y0, x0) = (fy.floor() as usize, fx.floor() as usize);
let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1));
let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32);
let g = |y: usize, x: usize| self.gains[(y * ph + x) * mp + p];
let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx;
let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx;
top + (bot - top) * ty
}
}
/// Radius normaliser: distance from the centre to the farthest image corner.
@@ -556,10 +586,17 @@ pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usi
}
}
Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains } => {
let grid = GainMapGrid {
points: [*points_v as usize, *points_h as usize],
spacing: *spacing,
origin: *origin,
planes: *map_planes as usize,
gains,
};
for (x, y, p) in area_iter(area, w, h, cpp) {
let rv = y as f64 / h as f64;
let rh = x as f64 / w as f64;
let g = gain_at(*points_v, *points_h, *spacing, *origin, *map_planes, gains, rv, rh, p - area.plane as usize);
let g = grid.sample_at([rv, rh], p - area.plane as usize);
buf[(y * w + x) * cpp + p] *= g;
}
}
+14
View File
@@ -1,5 +1,19 @@
use super::*;
#[test]
fn reject_opcode_counts_whose_bytes_exceed_the_remaining_data() {
let mut gain_map = Vec::new();
for value in [u32::MAX, 1] {
gain_map.extend_from_slice(&value.to_be_bytes());
}
gain_map.extend(be(&[1.0, 1.0, 0.0, 0.0]));
gain_map.extend_from_slice(&1u32.to_be_bytes());
assert!(parse_gain_map(Area::default(), &mut Rd { d: &gain_map, p: 0 }).is_none());
let delta_count = u32::MAX.to_be_bytes();
assert!(parse_delta_scale(10, Area::default(), &mut Rd { d: &delta_count, p: 0 }).is_none());
}
pub(crate) struct ListWriter(pub Vec<u8>, pub u32);
impl ListWriter {
+1 -1
View File
@@ -1,6 +1,6 @@
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel.
use crate::unpack::*;
use crate::unpack::{f16_to_f32, f24_to_f32, inflate, read_u16s, undo_diff_u16, undo_float_predictor, unpack_lsb, unpack_msb, unpack_words32_msb};
use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg};
use lightcraft_tiff::image::{Chunk, ImageInfo, chunk_bytes};
use lightcraft_tiff::{ByteOrder, tags::compression as comp};
+55 -21
View File
@@ -632,20 +632,23 @@ fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture {
/// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`,
/// everything else zero. Encoded at the given level count with the given quant/gain/base parameters.
fn constant_band_planes(
plane_w: usize,
plane_h: usize,
struct ConstantBandPlaneConfig<'a> {
plane_size: (usize, usize),
levels: u8,
ll3: i32,
mid_band: Option<(usize, i32)>,
quant: u8,
gains: [u32; 10],
bases: [u32; 10],
qp: Option<&[i32]>,
qp: Option<&'a [i32]>,
qp_padding: usize,
before: bool,
after: bool,
) -> (usize, Vec<u8>) {
boundary_bands: (bool, bool),
}
fn constant_band_planes(config: ConstantBandPlaneConfig<'_>) -> (usize, Vec<u8>) {
let ConstantBandPlaneConfig { plane_size: (plane_w, plane_h), levels, ll3, mid_band, quant, gains, bases, qp, qp_padding, boundary_bands } =
config;
let (before, after) = boundary_bands;
let planes: [Vec<Vec<u8>>; 4] = if levels == 0 {
let mut encoded: [Vec<Vec<u8>>; 4] = Default::default();
for bands in encoded.iter_mut() {
@@ -681,7 +684,18 @@ fn constant_band_planes(
/// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value.
fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture {
let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false);
let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig {
plane_size: (8, 8),
levels: 3,
ll3,
mid_band,
quant,
gains: [0; 10],
bases: [0; 10],
qp: None,
qp_padding: 0,
boundary_bands: (false, false),
});
let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32);
Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() }
}
@@ -697,7 +711,18 @@ fn v200_mapped_file(
bases: [u32; 10],
) -> Fixture {
assert_eq!(map.len(), (width / 16) * (height / 4));
let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false);
let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig {
plane_size: (width / 2, height / 2),
levels: 3,
ll3,
mid_band,
quant: 4,
gains,
bases,
qp: Some(map),
qp_padding: 3,
boundary_bands: (false, false),
});
let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32);
Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() }
}
@@ -811,20 +836,18 @@ proptest! {
}
*b = if i < 4 { base + 1 } else { base };
}
let (header_size, sample) = constant_band_planes(
width / 2,
height / 2,
3,
let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig {
plane_size: (width / 2, height / 2),
levels: 3,
ll3,
Some((4, mid)),
4,
mid_band: Some((4, mid)),
quant: 4,
gains,
bases,
Some(&qp_values[..map_w * map_h]),
3,
false,
false,
);
qp: Some(&qp_values[..map_w * map_h]),
qp_padding: 3,
boundary_bands: (false, false),
});
let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32);
let file = wrap(&coding, width as u32, height as u32, &sample, None);
let image = decode(&file).unwrap();
@@ -879,7 +902,18 @@ fn lossless_sample(value: i32) -> Vec<u8> {
/// Raw sample bytes of a constant-band 16×16 C-RAW fixture.
fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec<u8> {
let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false);
let (_, sample) = constant_band_planes(ConstantBandPlaneConfig {
plane_size: (8, 8),
levels: 3,
ll3,
mid_band,
quant,
gains: [0; 10],
bases: [0; 10],
qp: None,
qp_padding: 0,
boundary_bands: (false, false),
});
sample
}
+11 -4
View File
@@ -179,7 +179,14 @@ fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option
best
}
fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C {
struct WaterLight {
tint: C,
sun: (f32, f32),
sun_col: C,
}
fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, light: WaterLight) -> C {
let WaterLight { tint, sun, sun_col } = light;
let d = v - wl;
let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm);
let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0);
@@ -340,7 +347,7 @@ fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C {
};
if v > wl {
let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) };
return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col);
return water(p, x, v, wl, 0.75, &above, WaterLight { tint, sun, sun_col });
}
above(x, v)
}
@@ -419,7 +426,7 @@ fn ocean(p: &P, x: f32, v: f32) -> C {
if v < hz + head * 0.3 {
return hex(0x120c14);
}
return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col);
return water(p, x, v, hz, 0.35, &above, WaterLight { tint: hex(0x1a2040), sun, sun_col });
}
let _ = head;
above(x, v)
@@ -465,7 +472,7 @@ fn aurora(p: &P, x: f32, v: f32) -> C {
add(sky(p, &sk, x, v), aur(x, v))
};
if v > wl {
return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]);
return water(p, x, v, wl, 0.9, &above, WaterLight { tint: hex(0x020408), sun: (-5.0, 5.0), sun_col: [0.0; 3] });
}
above(x, v)
}
+3
View File
@@ -12,6 +12,9 @@ description = "LightCraft sysmem (L0): returns freed allocator memory to the OS.
unsafe_code = "deny"
[lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
+7 -1
View File
@@ -1,7 +1,13 @@
use super::clock::{AppClock, monotonic_ms};
use super::services::Services;
use super::state::{GestureFrame, InjectedDrop, InjectedPress};
use super::*;
use super::{
AiModel, Caches, CommandError, ControlRequest, ControlResponse, ErrorKind, GESTURE_TIMEOUT_MS, LightcraftApp, MAX_PENDING_GESTURES,
MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, MAX_TEXTURE_SIDE, PendingGesture, PendingShot, Perf, Receiver, SCREEN_PROFILE,
SCREENSHOT_FALLBACK_MS, SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, Session, ShotGeometry,
TOAST_SECONDS, UiState, Value, docking, drop_overlay, export_task, focus_scan, frame_rate, headless, i18n, import, lightroom_import, menus,
merge, model_setup, panels, pick, recheck, render, shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets,
};
mod control;
mod frame;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{ControlResponse, GESTURE_TIMEOUT_MS, GestureFrame, LightcraftApp, MAX_PENDING_GESTURES, PendingGesture, Sender, Value};
use crate::control;
impl LightcraftApp {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
LightcraftApp, docking, drop_overlay, export_task, focus_scan, import, lightroom_import, merge, model_setup, monotonic_ms, panels, pick, recheck,
shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets,
};
impl LightcraftApp {
/// Install/refresh fonts once and watch the device and availability caches. Setup, not input.
+12 -12
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{GESTURE_TIMEOUT_MS, InjectedDrop, InjectedPress, LightcraftApp, drop_overlay, import, panels};
impl LightcraftApp {
/// Preserve the currently queued handles while a prompt or blocking library window owns
@@ -262,7 +262,7 @@ mod drop_tests {
fn photo_sidecars_are_not_presets() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]);
let (presets, photos) = super::split_presets(dropped);
let (presets, photos) = crate::split_presets(dropped);
assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"]));
assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"]));
}
@@ -271,31 +271,31 @@ mod drop_tests {
fn detection_results_are_summarised_in_words() {
let r =
|counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::<Vec<_>>()});
assert_eq!(super::detect_summary(&r(&[0])), "No faces found");
assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face");
assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces");
assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos");
assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos");
assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos");
assert_eq!(crate::app::detect_summary(&r(&[0])), "No faces found");
assert_eq!(crate::app::detect_summary(&r(&[1])), "Found 1 face");
assert_eq!(crate::app::detect_summary(&r(&[3])), "Found 3 faces");
assert_eq!(crate::app::detect_summary(&r(&[0, 0])), "No faces found in 2 photos");
assert_eq!(crate::app::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos");
assert_eq!(crate::app::detect_summary(&serde_json::json!({})), "No faces found in 0 photos");
}
#[test]
fn dropped_face_models_are_told_apart() {
for p in ["/a/model.onnx", "/a/dir/M.ONNX"] {
assert!(super::is_model_file(p), "{p}");
assert!(crate::is_model_file(p), "{p}");
}
for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] {
assert!(!super::is_model_file(p), "{p}");
assert!(!crate::is_model_file(p), "{p}");
}
}
#[test]
fn dropped_presets_are_told_apart_from_photos() {
for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] {
assert!(super::is_preset_file(p), "{p}");
assert!(crate::is_preset_file(p), "{p}");
}
for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] {
assert!(!super::is_preset_file(p), "{p}");
assert!(!crate::is_preset_file(p), "{p}");
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{LightcraftApp, SCREEN_PROFILE, theme};
impl LightcraftApp {
/// The system's appearance: the host's answer first, then egui's (none: unknown).
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, PendingShot, SCREENSHOT_FALLBACK_MS,
SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, ShotGeometry, headless,
};
use crate::control;
impl LightcraftApp {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Caches, album_picker, i18n, panels};
/// The left panel's counts.
#[derive(Clone, Copy, Debug, Default)]
+1662 -1313
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
use super::cull::cull_params;
use super::export::export_dialog_params;
use super::*;
use super::{Dialog, LightcraftApp, json};
/// Runs a command that creates an album and asks the Albums tree to open the folders down to it.
fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result<serde_json::Value, String> {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::json;
/// Apply a dialog's action (also used by `ui.dialog.confirm`).
/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day`
+1 -1
View File
@@ -1,5 +1,5 @@
use super::frame::{field, num};
use super::*;
use super::{ControlSpec, LightcraftApp, Section, SettingsGroup, Tokens, Track};
/// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever
/// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` =
+1 -1
View File
@@ -1,5 +1,5 @@
use super::export::LABEL_W;
use super::*;
use super::{BACKDROP_SHOWN, ControlSpec, Dialog, LightcraftApp, Tokens};
/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and
/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a
+1 -1
View File
@@ -1,5 +1,5 @@
use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning};
use super::*;
use super::{Align2, Color32, ImportDialog, LightcraftApp, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, Vec2, pos2, register, vec2};
/// The review dialog's first size (points). It can be resized; the photo grid takes the height.
pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0];
+1 -1
View File
@@ -1,5 +1,5 @@
use super::task::ImportTask;
use super::*;
use super::{ImportDialog, LightcraftApp, Ordering, ScanInput, ScanOutput, ScanProgress, Value, json, scan_with};
/// A folder scan running on a worker thread (a network share can take minutes to read; the
/// window must keep answering meanwhile).

Some files were not shown because too many files have changed in this diff Show More