mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
Merge pull request #3111 from dylanmtaylor/sonar/enforce-clippy-standards
Enforce Rust coding standards via Clippy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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,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,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,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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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,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,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,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,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();
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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,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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, MediaKind, Photo, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ImportMode {
|
||||
|
||||
@@ -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,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,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)]
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, DevelopSettings, HashMap, PhotoId, VecDeque, Weak};
|
||||
|
||||
pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,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)]
|
||||
|
||||
@@ -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,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,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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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)];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ControlResponse, GESTURE_TIMEOUT_MS, GestureFrame, LightcraftApp, MAX_PENDING_GESTURES, PendingGesture, Sender, Value};
|
||||
use crate::control;
|
||||
|
||||
impl LightcraftApp {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Caches, album_picker, i18n, panels};
|
||||
|
||||
/// The left panel's counts.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
||||
+1662
-1313
File diff suppressed because it is too large
Load Diff
@@ -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,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,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,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,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,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
Reference in New Issue
Block a user