Merge pull request #998 from storytold/claude/integrate-batch-31
Integration batch 31: translations, raw header previews, XMP import warnings, Apple Photos, HDR histogram, JPEG XL, Edit In for many photos
This commit is contained in:
89 files changed
+5796
-475
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
|
||||
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
|
||||
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
|
||||
# searches (a few minutes).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -71,3 +73,17 @@ jobs:
|
||||
EOF
|
||||
- name: FreeBSD tarball layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
|
||||
windows-install-folder:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Install-folder fallback and precedence (real MSI AppSearch)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x64
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x86
|
||||
Generated
+8
@@ -2693,6 +2693,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jixel"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3130,6 +3136,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jixel",
|
||||
"jpeg-decoder",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3209,6 +3216,7 @@ version = "0.6.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
|
||||
@@ -70,6 +70,7 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
|
||||
|
||||
@@ -314,6 +314,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
|
||||
|
||||
|
||||
@@ -52,17 +52,19 @@ USAGE:
|
||||
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
|
||||
--compact list only the helper tools (every command stays reachable via run_command)
|
||||
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
|
||||
same encoder as the app's Export dialog. Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
--settings FILE merge a partial develop-settings JSON file
|
||||
--preset ID apply a preset (see `commands`/presets.list)
|
||||
--size N long edge in pixels (default: full size, cropped)
|
||||
--quality Q JPEG/AVIF quality 1..100 (default 92)
|
||||
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
|
||||
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
|
||||
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
|
||||
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
|
||||
--opt format=original (copy + XMP sidecar) --opt metadata=none
|
||||
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
|
||||
the file to Apple Photos, into that album)
|
||||
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
|
||||
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
|
||||
Options:
|
||||
@@ -698,6 +700,8 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = s.after_export(&o)?;
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
@@ -712,6 +716,12 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
if let Some(photos) = after.run(&[json!({"path": output})], false) {
|
||||
if let Some(err) = photos.get("error").and_then(Value::as_str) {
|
||||
return Err(format!("render: {output} was written, but {err}"));
|
||||
}
|
||||
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ pub fn mime_for(name: &str) -> &'static str {
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
Some("jxl") => "image/jxl",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +188,18 @@ impl WorkerCore {
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
let mut rendered = match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
};
|
||||
// an HDR edit's histogram shows its HDR range (as on native)
|
||||
if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
Ok(rendered)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
Ok((r, stored)) => {
|
||||
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
|
||||
let h = &r.histogram;
|
||||
let hist = Uint32Array::new_with_length(4 * 256 + 1);
|
||||
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
|
||||
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
|
||||
}
|
||||
hist.set_index(1024, h.total);
|
||||
let hist = Uint32Array::from(h.to_words().as_slice());
|
||||
set(&out, "ok", &true.into());
|
||||
set(&out, "w", &(r.image.width as u32).into());
|
||||
set(&out, "h", &(r.image.height as u32).into());
|
||||
@@ -326,12 +322,8 @@ impl Workers {
|
||||
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
|
||||
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
|
||||
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
|
||||
match Rgba8::from_bytes(wd, ht, &rgba) {
|
||||
Some(image) if hist.len() == 1025 => {
|
||||
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
|
||||
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {
|
||||
(Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),
|
||||
_ => Err("render worker: bad image".to_string()),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -595,6 +595,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
|
||||
s.apple_photos_background = true;
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
@@ -945,6 +947,16 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
|
||||
/// adds to Apple Photos on a worker (headless sessions wait instead).
|
||||
#[test]
|
||||
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
|
||||
let (s, problem) = open_session(true, None, false);
|
||||
assert!(problem.is_none());
|
||||
assert!(s.apple_photos_background);
|
||||
assert!(!lightcraft_engine::Session::new().apple_photos_background);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
|
||||
@@ -32,11 +32,14 @@ jxl-oxide = { workspace = true, optional = true }
|
||||
ravif = { workspace = true, optional = true }
|
||||
rav1e = { workspace = true, optional = true }
|
||||
avif-serialize = { workspace = true, optional = true }
|
||||
# spawns threads of its own (std::thread), which wasm32 has not got
|
||||
jixel = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
|
||||
jxl = ["dep:jxl-oxide", "dep:jixel"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
|
||||
@@ -45,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Lossless synthetic HEIC files for the `heif` feature's tests.
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
@@ -23,6 +23,7 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
|
||||
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
|
||||
encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
|
||||
|
||||
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
|
||||
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
|
||||
@@ -51,7 +52,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
|
||||
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
|
||||
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
|
||||
@@ -87,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
|
||||
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
|
||||
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
|
||||
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
|
||||
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
|
||||
|
||||
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and,
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
|
||||
//! embedded where the format allows.
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
|
||||
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
|
||||
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
|
||||
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
|
||||
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
|
||||
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
@@ -24,11 +26,13 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let gray = header.metadata.grayscale();
|
||||
let icc = img.original_icc().map(|v| v.to_vec());
|
||||
let mut meta = Meta { orientation: Some(1), ..Default::default() };
|
||||
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
|
||||
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
|
||||
if icc.is_none() {
|
||||
img.request_color_encoding(EnumColourEncoding {
|
||||
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
|
||||
white_point: WhitePoint::D65,
|
||||
primaries: Primaries::Bt2100,
|
||||
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
|
||||
tf: TransferFunction::Linear,
|
||||
rendering_intent: RenderingIntent::Relative,
|
||||
});
|
||||
@@ -55,11 +59,19 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let keep = n_color + has_alpha as usize;
|
||||
let src = fb.buf();
|
||||
let mut v = Vec::with_capacity(fw * fh * keep);
|
||||
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
|
||||
for px in src.chunks_exact(ch) {
|
||||
let start = v.len();
|
||||
v.extend_from_slice(&px[..keep.min(ch)]);
|
||||
if keep > ch {
|
||||
v.push(1.0);
|
||||
}
|
||||
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
|
||||
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
|
||||
for (o, m) in rgb.iter_mut().zip(&to_2020) {
|
||||
*o = m[0] * r + m[1] * g + m[2] * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = Raw {
|
||||
width: fw,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
|
||||
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
|
||||
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
|
||||
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
|
||||
|
||||
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
|
||||
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
|
||||
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
|
||||
/// exports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JxlEffort {
|
||||
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
|
||||
Fast,
|
||||
#[default]
|
||||
Normal,
|
||||
}
|
||||
|
||||
impl JxlEffort {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"fast" => Self::Fast,
|
||||
"normal" => Self::Normal,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum JxlColour {
|
||||
#[default]
|
||||
Srgb,
|
||||
/// P3 primaries, D65, sRGB curve.
|
||||
DisplayP3,
|
||||
/// Rec. 2020 primaries with the BT.709 curve.
|
||||
Rec2020,
|
||||
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
|
||||
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
|
||||
/// samples are already PQ-encoded.
|
||||
Rec2020Pq,
|
||||
}
|
||||
|
||||
/// JPEG XL encoder settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct JxlOptions {
|
||||
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
|
||||
pub quality: u8,
|
||||
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
|
||||
pub lossless: bool,
|
||||
pub effort: JxlEffort,
|
||||
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
|
||||
/// preview that refines to the exact image).
|
||||
pub progressive: bool,
|
||||
pub colour: JxlColour,
|
||||
}
|
||||
|
||||
impl Default for JxlOptions {
|
||||
fn default() -> Self {
|
||||
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
|
||||
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
|
||||
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
|
||||
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
|
||||
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let pixels = Pixels::from(img, o.colour)?;
|
||||
// jixel asserts internally; a panic there is an export error, not a crash
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
|
||||
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
|
||||
}
|
||||
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (o, meta);
|
||||
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved RGB or RGBA samples for jixel.
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
enum Pixels {
|
||||
U8 { data: Vec<u8>, alpha: bool },
|
||||
U16 { data: Vec<u16>, alpha: bool },
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
impl Pixels {
|
||||
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
|
||||
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
let ch = img.channels as usize;
|
||||
// keep alpha only when it says something
|
||||
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
|
||||
let px = s.chunks_exact(ch).take(n);
|
||||
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
|
||||
let out_ch = if alpha { 4 } else { 3 };
|
||||
let mut v = Vec::with_capacity(n * out_ch);
|
||||
for c in px {
|
||||
match c {
|
||||
[g] => v.extend_from_slice(&[*g, *g, *g]),
|
||||
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
|
||||
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
|
||||
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
|
||||
[] => {}
|
||||
}
|
||||
}
|
||||
(v, alpha)
|
||||
}
|
||||
Ok(match img.samples {
|
||||
Samples::U8(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
|
||||
Pixels::U8 { data, alpha }
|
||||
}
|
||||
Samples::U16(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
|
||||
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
|
||||
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
|
||||
let s: Vec<u16> = s
|
||||
.chunks_exact(ch)
|
||||
.take(n)
|
||||
.flat_map(|c| match c {
|
||||
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
|
||||
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
|
||||
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
|
||||
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
|
||||
[] => [0; 4],
|
||||
})
|
||||
.collect();
|
||||
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
|
||||
let colour = match o.colour {
|
||||
JxlColour::Srgb => ColorEncoding::srgb(),
|
||||
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
|
||||
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
|
||||
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
|
||||
};
|
||||
let mut c = EncodeConfig::default()
|
||||
.with_quality(f32::from(o.quality.clamp(1, 100)))
|
||||
.with_color_encoding(colour)
|
||||
.with_lossless(o.lossless)
|
||||
.with_progressive(o.progressive)
|
||||
.with_speed(match o.effort {
|
||||
JxlEffort::Fast => Speed::Fastest,
|
||||
JxlEffort::Normal => Speed::Fast,
|
||||
});
|
||||
if o.colour == JxlColour::Rec2020Pq {
|
||||
// PQ code values are absolute: 1.0 = 10 000 cd/m²
|
||||
c = c.with_intensity_target(10_000.0);
|
||||
}
|
||||
if o.lossless
|
||||
&& let Some(icc) = meta.icc
|
||||
{
|
||||
c = c.with_icc_profile(icc.to_vec());
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
c = c.with_exif(exif.to_vec());
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
c = c.with_xmp(xmp.as_bytes().to_vec());
|
||||
}
|
||||
let r = match px {
|
||||
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
|
||||
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
|
||||
};
|
||||
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
|
||||
//! decoding pixels (import probes).
|
||||
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
|
||||
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
@@ -27,6 +28,7 @@ mod jpeg;
|
||||
pub mod jpeg_par;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
pub mod proof;
|
||||
@@ -40,6 +42,7 @@ pub use encode::{
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
|
||||
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
|
||||
};
|
||||
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
|
||||
pub use sniff::{Format, sniff};
|
||||
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
|
||||
|
||||
|
||||
@@ -66,6 +66,15 @@ fn bench_24mp_jpeg() {
|
||||
let rgba = d.to_srgb8();
|
||||
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
|
||||
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
|
||||
#[cfg(feature = "jxl")]
|
||||
for (label, o) in [
|
||||
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
|
||||
("encode JPEG XL q90 effort normal", JxlOptions::default()),
|
||||
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
|
||||
] {
|
||||
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
|
||||
println!(" size {:.1} MB", b.len() as f64 / 1e6);
|
||||
}
|
||||
}
|
||||
|
||||
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
|
||||
|
||||
@@ -681,3 +681,137 @@ fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
|
||||
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
|
||||
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
|
||||
}
|
||||
|
||||
/// Largest per-channel difference between two linear images.
|
||||
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossless_exact_with_metadata() {
|
||||
let img = gradient(97, 61);
|
||||
let icc = write_named(NamedSpace::Srgb);
|
||||
let exif = minimal_exif(1);
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
|
||||
let o = JxlOptions { lossless: true, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
|
||||
assert_eq!(sniff(&b), Some(Format::Jxl));
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (97, 61));
|
||||
assert!(!d.has_alpha, "opaque alpha is dropped");
|
||||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||||
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
|
||||
assert_eq!(d.xmp.as_deref(), Some(xmp));
|
||||
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
|
||||
|
||||
// 16-bit lossless keeps every code value
|
||||
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
|
||||
let want = decode(&png, DecodeOptions::default()).unwrap();
|
||||
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_quality_and_effort() {
|
||||
let img = smooth(160, 120);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
|
||||
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
|
||||
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
|
||||
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
|
||||
for (q, b) in [(95, &hi), (40, &lo)] {
|
||||
let d = decode(b, DecodeOptions::default()).unwrap();
|
||||
let p = psnr8(&opaque, &d.to_srgb8());
|
||||
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
|
||||
}
|
||||
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
|
||||
for progressive in [false, true] {
|
||||
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
|
||||
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
|
||||
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
|
||||
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
|
||||
// profile, decode to the same colours in the working space
|
||||
let img = smooth(96, 64);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
|
||||
let icc = write_named(named);
|
||||
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
|
||||
let e = EncodeImage::rgba8(&opaque);
|
||||
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
|
||||
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
|
||||
let a = decode(&lossy, DecodeOptions::default()).unwrap();
|
||||
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
|
||||
let b = decode(&exact, DecodeOptions::default()).unwrap();
|
||||
let d = max_diff(&a.to_working(), &b.to_working());
|
||||
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_alpha_and_gray() {
|
||||
let mut img = gradient(32, 24);
|
||||
img.data[5][3] = 0;
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert!(d.has_alpha);
|
||||
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
|
||||
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
|
||||
.unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_hdr_pq() {
|
||||
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
|
||||
let (w, h) = (64usize, 32usize);
|
||||
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
|
||||
for lossless in [false, true] {
|
||||
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
|
||||
// the file's own samples: PQ code values for 203 and 812 cd/m²
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
|
||||
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
|
||||
let r = img.render_frame(0).unwrap();
|
||||
let fb = r.image_all_channels();
|
||||
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
|
||||
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
|
||||
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
|
||||
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
|
||||
}
|
||||
}
|
||||
// float samples are HDR only
|
||||
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
|
||||
assert!(matches!(sdr, Err(Error::Encode(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_bad_input_is_an_error() {
|
||||
let px = [0u8; 12];
|
||||
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
// a 1×1 image and quality 1 still encode
|
||||
let one = [10u8, 20, 30];
|
||||
let b =
|
||||
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
|
||||
}
|
||||
@@ -51,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
@@ -100,6 +100,12 @@ struct Entry {
|
||||
detail: Mutex<String>,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
cancellable: AtomicBool,
|
||||
/// Held while a cancel is decided ([`Activity::cancel`], [`Activity::cancel_all`]) and while
|
||||
/// cancelling is switched ([`TaskGuard::set_cancellable`], [`TaskHandle::set_cancellable`]): a
|
||||
/// step that turns it off and then reads the cancel flag either sees a cancel that was
|
||||
/// accepted, or makes the cancel fail. Without it a cancel could pass the check, the step
|
||||
/// start, and the flag be set after the step's last look at it: accepted but ignored.
|
||||
gate: Mutex<()>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
@@ -111,6 +117,23 @@ fn capped(text: &str) -> String {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn set_cancellable(&self, yes: bool) {
|
||||
let _gate = lock(&self.gate);
|
||||
self.cancellable.store(yes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set the cancel flag if the task can be cancelled now (see [`Entry::gate`]).
|
||||
fn try_cancel(&self) -> bool {
|
||||
let _gate = lock(&self.gate);
|
||||
match &self.cancel {
|
||||
Some(flag) if self.is_cancellable() => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancellable(&self) -> bool {
|
||||
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -165,6 +188,7 @@ impl Activity {
|
||||
detail: Mutex::new(String::new()),
|
||||
cancellable: AtomicBool::new(cancel.is_some()),
|
||||
cancel,
|
||||
gate: Mutex::new(()),
|
||||
});
|
||||
lock(&self.inner.tasks).push(entry.clone());
|
||||
TaskGuard { activity: self.clone(), entry }
|
||||
@@ -180,26 +204,13 @@ impl Activity {
|
||||
pub fn cancel(&self, id: u64) -> Result<(), String> {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
|
||||
match (&entry.cancel, entry.is_cancellable()) {
|
||||
(Some(flag), true) => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{} can't be cancelled", entry.label)),
|
||||
}
|
||||
if entry.try_cancel() { Ok(()) } else { Err(format!("{} can't be cancelled", entry.label)) }
|
||||
}
|
||||
|
||||
/// Ask every cancellable task to stop; returns how many were asked.
|
||||
pub fn cancel_all(&self) -> usize {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let mut n = 0;
|
||||
for e in tasks.iter().filter(|e| e.is_cancellable()) {
|
||||
if let Some(flag) = &e.cancel {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
tasks.iter().filter(|e| e.try_cancel()).count()
|
||||
}
|
||||
|
||||
/// Tasks that quitting would cut short: cancellable ones not already stopping.
|
||||
@@ -235,7 +246,7 @@ impl TaskGuard {
|
||||
|
||||
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.cancellable.store(yes, Ordering::Relaxed);
|
||||
self.entry.set_cancellable(yes);
|
||||
}
|
||||
|
||||
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
|
||||
@@ -283,6 +294,13 @@ impl TaskHandle {
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.entry.is_cancelled()
|
||||
}
|
||||
|
||||
/// [`TaskGuard::set_cancellable`] from the worker, at the moment its step starts (an export
|
||||
/// handing its files to Apple Photos, which can't be stopped once asked). Read the cancel flag
|
||||
/// after turning it off: a cancel accepted before is seen then, any later one is refused.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.set_cancellable(yes);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -380,6 +398,35 @@ mod tests {
|
||||
assert_eq!(a.list().len(), 1);
|
||||
}
|
||||
|
||||
/// Codex review of #643: a worker that turns cancelling off and then reads the cancel flag
|
||||
/// (an export handing its files to Apple Photos) must either see a cancel that was accepted or
|
||||
/// make it fail, never let one be accepted and then ignored. Here the test holds the task's gate
|
||||
/// as a cancel does while it decides: the worker's switch waits for it, and then sees the cancel.
|
||||
#[test]
|
||||
fn a_step_that_turns_cancelling_off_sees_or_refuses_every_cancel() {
|
||||
let a = Activity::default();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
|
||||
let handle = g.handle();
|
||||
let gate = lock(&g.entry.gate); // a cancel deciding
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (h, f) = (handle.clone(), flag.clone());
|
||||
let worker = std::thread::spawn(move || {
|
||||
h.set_cancellable(false);
|
||||
tx.send(f.load(Ordering::Relaxed)).unwrap();
|
||||
});
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "the switch waits for the cancel");
|
||||
flag.store(true, Ordering::Relaxed); // the cancel goes through...
|
||||
drop(gate);
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(), "...and the step sees it");
|
||||
worker.join().unwrap();
|
||||
// once it is off, cancels are refused
|
||||
let g2 = a.start("export", "Exporting", Cancel::Yes);
|
||||
g2.handle().set_cancellable(false);
|
||||
assert!(a.cancel(g2.id()).is_err());
|
||||
assert_eq!(a.cancel_all(), 0, "{:?}", a.list());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_updates_from_another_thread() {
|
||||
let a = Activity::default();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -124,15 +124,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let mut ids = s.targets(p);
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no active photo"));
|
||||
}
|
||||
if ids.len() > MAX_EDIT_COPIES {
|
||||
return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len())));
|
||||
}
|
||||
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
|
||||
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
|
||||
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
|
||||
@@ -157,6 +156,31 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
conflict: crate::export::Conflict::Unique,
|
||||
..Default::default()
|
||||
};
|
||||
let mut copies = Vec::new();
|
||||
for id in ids {
|
||||
copies.push(edit_copy(s, p, id, &opts)?);
|
||||
}
|
||||
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
|
||||
s.selection = crate::Selection { active: new.first().copied(), ids: new };
|
||||
let first = copies.first().cloned().unwrap_or(Value::Null);
|
||||
Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies}))
|
||||
}
|
||||
|
||||
/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo).
|
||||
const MAX_EDIT_COPIES: usize = 50;
|
||||
|
||||
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
|
||||
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
|
||||
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
// durable: the TIFF becomes a library photo the external editor then changes
|
||||
@@ -165,14 +189,13 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
};
|
||||
let to = crate::export::Destination { dir, exact: None };
|
||||
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let ext = format!(".{}", format.extension());
|
||||
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let ext = format!(".{}", opts.format.extension());
|
||||
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
||||
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
||||
// stack: the edit on top of the original, expanded so both show
|
||||
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
|
||||
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
|
||||
Ok(json!({"path": out, "id": new, "original": id.0}))
|
||||
}
|
||||
|
||||
@@ -363,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Edit Copy for External Editor",
|
||||
[],
|
||||
None,
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render the active photo with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
"{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)",
|
||||
has_active,
|
||||
edit_external
|
||||
),
|
||||
|
||||
@@ -78,8 +78,76 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
match &s.apple_photos {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(crate::apple_photos::unavailable().into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
|
||||
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
|
||||
/// worker thread (the default in the desktop app, which answers control requests on its UI
|
||||
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.addToPhotos";
|
||||
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
|
||||
}
|
||||
let paths: Vec<String> = match p.get("paths") {
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.map(|v| v.as_str().map(str::to_string))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
|
||||
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
|
||||
};
|
||||
let album = match p.get("album") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(a)) => a.as_str(),
|
||||
Some(_) => return Err(bad(ID, "`album` must be a string")),
|
||||
};
|
||||
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
|
||||
// in the middle of the import
|
||||
let wait = match p.get("wait") {
|
||||
None | Some(Value::Null) => !s.apple_photos_background,
|
||||
// the desktop app answers on its window's thread, which never waits for Photos
|
||||
Some(Value::Bool(true)) if s.apple_photos_background => {
|
||||
return Err(bad(
|
||||
ID,
|
||||
"`wait: true` isn't available in the desktop app: the import runs in the background; its outcome comes from export.photosImports {job}",
|
||||
));
|
||||
}
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
|
||||
};
|
||||
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait, Some(&s.activity)).map_err(|e| bad(ID, e))?;
|
||||
match job.outcome() {
|
||||
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
|
||||
_ => Ok(job.json()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.photosImports";
|
||||
match p.get("job") {
|
||||
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
|
||||
Some(v) => {
|
||||
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
|
||||
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
@@ -123,5 +191,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
];
|
||||
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
|
||||
// replay must not import again.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.addToPhotos",
|
||||
"Add to Apple Photos",
|
||||
[],
|
||||
None,
|
||||
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app, where wait: true is refused (its control requests are answered on the UI thread, which never waits for Photos). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
|
||||
photos_enabled,
|
||||
add_to_photos
|
||||
));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.photosImports",
|
||||
"Apple Photos Imports",
|
||||
[],
|
||||
None,
|
||||
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
|
||||
always,
|
||||
photos_imports
|
||||
));
|
||||
v
|
||||
}
|
||||
@@ -1247,7 +1247,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
@@ -1303,7 +1303,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
s.selection = sel;
|
||||
Ok(json!({"imported": r["imported"], "folder": folder}))
|
||||
let mut result = json!({"imported": r["imported"], "folder": folder});
|
||||
if let Some(warnings) = r.get("warnings") {
|
||||
result["warnings"] = warnings.clone();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
),
|
||||
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
|
||||
@@ -1365,7 +1369,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let req = import_params(s, p)?;
|
||||
|
||||
@@ -35,11 +35,13 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
let mut read = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
match s.read_sidecar_op(id) {
|
||||
Ok(Some((op, from))) => {
|
||||
match s.read_sidecar_op_with_warnings(id) {
|
||||
Ok(Some((op, from, mut photo_warnings))) => {
|
||||
ops.push(op);
|
||||
read.push(json!({"id": id.0, "from": from.display().to_string()}));
|
||||
warnings.append(&mut photo_warnings);
|
||||
}
|
||||
Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})),
|
||||
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
|
||||
@@ -48,7 +50,11 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !ops.is_empty() {
|
||||
s.commit("Read Metadata from File", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"read": read, "failed": failed}))
|
||||
let mut result = json!({"read": read, "failed": failed});
|
||||
if !warnings.is_empty() {
|
||||
result["warnings"] = serde_json::to_value(warnings).unwrap_or_default();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -84,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Read Metadata from File",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed}",
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
has_selection,
|
||||
read
|
||||
),
|
||||
|
||||
@@ -505,6 +505,22 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
(out, unmapped)
|
||||
}
|
||||
|
||||
/// The selected camera profile name from an XMP packet. The name is metadata only: it does not
|
||||
/// identify or carry the profile's rendering data.
|
||||
pub fn camera_profile_name(props: &Props) -> Option<&str> {
|
||||
first(props, "crs:CameraProfile")
|
||||
}
|
||||
|
||||
/// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a
|
||||
/// coordinate convention that LightCraft does not currently import.
|
||||
pub fn has_saved_upright_transform(props: &Props) -> bool {
|
||||
props.keys().any(|key| {
|
||||
key.strip_prefix("crs:").is_some_and(|name| {
|
||||
name.starts_with("UprightTransform_") && props.get(key).is_some_and(|values| values.iter().any(|v| !v.trim().is_empty()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
|
||||
/// Returns `None` when the packet has no adjustments we understand.
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
|
||||
+277
-17
@@ -1,11 +1,11 @@
|
||||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF (or copy the original / write a
|
||||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF / JPEG XL (or copy the original / write a
|
||||
//! DNG, each with the edits in XMP) in sRGB, Display P3, Adobe RGB
|
||||
//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the
|
||||
//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
|
||||
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
|
||||
|
||||
pub use lightcraft_codecs::TiffCompression;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, encode, icc};
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, JxlColour, JxlOptions, NamedSpace, Samples, encode, icc};
|
||||
pub use lightcraft_codecs::{JxlEffort, TiffCompression};
|
||||
use lightcraft_meta::{DateTime, Gps, Metadata};
|
||||
pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};
|
||||
use lightcraft_raster::Rgba8;
|
||||
@@ -22,6 +22,8 @@ pub enum ExportFormat {
|
||||
Psd,
|
||||
Webp,
|
||||
Avif,
|
||||
/// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ.
|
||||
Jxl,
|
||||
/// The original file, unchanged, with an XMP sidecar holding the edits.
|
||||
Original,
|
||||
/// A DNG of the raw data (raw photos only), the edits embedded as XMP.
|
||||
@@ -37,6 +39,7 @@ impl ExportFormat {
|
||||
"psd" => Self::Psd,
|
||||
"webp" => Self::Webp,
|
||||
"avif" => Self::Avif,
|
||||
"jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl,
|
||||
"original" => Self::Original,
|
||||
"dng" => Self::Dng,
|
||||
_ => return None,
|
||||
@@ -50,6 +53,7 @@ impl ExportFormat {
|
||||
Self::Psd => "psd",
|
||||
Self::Webp => "webp",
|
||||
Self::Avif => "avif",
|
||||
Self::Jxl => "jxl",
|
||||
// the original keeps its own extension (see `ExportOptions::file_name_for`)
|
||||
Self::Original => "",
|
||||
Self::Dng => "dng",
|
||||
@@ -623,7 +627,7 @@ pub enum MetadataPolicy {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportOptions {
|
||||
pub format: ExportFormat,
|
||||
/// 1–100 (JPEG, AVIF).
|
||||
/// 1–100 (JPEG, AVIF, lossy JPEG XL).
|
||||
pub quality: u8,
|
||||
/// Output size (`None` = full size).
|
||||
pub resize: Option<Resize>,
|
||||
@@ -654,18 +658,29 @@ pub struct ExportOptions {
|
||||
pub remove_location: bool,
|
||||
/// Text watermark (none when absent or the text is empty).
|
||||
pub watermark: Option<Watermark>,
|
||||
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile).
|
||||
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile; lossy JPEG XL
|
||||
/// writes Adobe RGB and ProPhoto as Rec. 2020, see [`ExportOptions::effective_space`]).
|
||||
pub color_space: OutputSpace,
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF, JPEG XL), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
|
||||
pub bit_depth: Option<u8>,
|
||||
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
|
||||
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
|
||||
/// without cICP support show it as sRGB, washed out), 32-bit float
|
||||
/// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float
|
||||
/// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR
|
||||
/// edit, are SDR.
|
||||
pub hdr: bool,
|
||||
/// JPEG XL: mathematically lossless instead of lossy (`quality` is then unused).
|
||||
pub jxl_lossless: bool,
|
||||
/// JPEG XL: encoder effort (time spent for a smaller file).
|
||||
pub jxl_effort: JxlEffort,
|
||||
/// JPEG XL: progressive file (a coarse image first, refined as it loads).
|
||||
pub jxl_progressive: bool,
|
||||
/// After the export, add the files written to Apple Photos (macOS only; see [`AfterExport`]).
|
||||
pub add_to_photos: bool,
|
||||
/// The Photos album they go into, made when missing (empty = none).
|
||||
pub photos_album: String,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -690,6 +705,11 @@ impl Default for ExportOptions {
|
||||
color_space: OutputSpace::Srgb,
|
||||
bit_depth: None,
|
||||
hdr: false,
|
||||
jxl_lossless: false,
|
||||
jxl_effort: JxlEffort::Normal,
|
||||
jxl_progressive: false,
|
||||
add_to_photos: false,
|
||||
photos_album: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,8 +746,19 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
"hdr",
|
||||
"jxlLossless",
|
||||
"jxlEffort",
|
||||
"jxlProgressive",
|
||||
"addToPhotos",
|
||||
"photosAlbum",
|
||||
];
|
||||
|
||||
/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the
|
||||
/// session has the runner; see [`crate::Session::after_export`]).
|
||||
pub fn apple_photos_unsupported() -> Option<&'static str> {
|
||||
(!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS")
|
||||
}
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY`
|
||||
/// together (what versions before the two margins wrote); either of those, when given, wins.
|
||||
pub const WATERMARK_PARAMS: &[&str] =
|
||||
@@ -842,7 +873,7 @@ impl ExportOptions {
|
||||
match k.as_str() {
|
||||
"format" => {
|
||||
if ExportFormat::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original")));
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original")));
|
||||
}
|
||||
}
|
||||
"quality" => {
|
||||
@@ -872,9 +903,21 @@ impl ExportOptions {
|
||||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"addToPhotos" => {
|
||||
if boolean(k, v)?
|
||||
&& let Some(why) = apple_photos_unsupported()
|
||||
{
|
||||
return Err(bad(format!("`{k}`: {why}")));
|
||||
}
|
||||
}
|
||||
"photosAlbum" => {
|
||||
if string(cmd, k, v)?.contains('\0') {
|
||||
return Err(bad(format!("`{k}` can't contain a NUL character")));
|
||||
}
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
@@ -882,6 +925,7 @@ impl ExportOptions {
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
"dngCompression" => {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
|
||||
@@ -965,7 +1009,8 @@ impl ExportOptions {
|
||||
}
|
||||
|
||||
/// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`,
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`,
|
||||
/// `addToPhotos`, `photosAlbum`) and the
|
||||
/// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H),
|
||||
/// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus
|
||||
/// `dontEnlarge` (default true).
|
||||
@@ -1011,6 +1056,11 @@ impl ExportOptions {
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
|
||||
jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless),
|
||||
jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort),
|
||||
jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive),
|
||||
add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos),
|
||||
photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1088,6 +1138,9 @@ impl ExportOptions {
|
||||
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) => OutputDepth::U8,
|
||||
(ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Jxl, _) => OutputDepth::U8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1099,29 +1152,46 @@ impl ExportOptions {
|
||||
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
|
||||
ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")],
|
||||
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
|
||||
ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
|
||||
/// or PNG (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
|
||||
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF,
|
||||
/// PNG or JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
|
||||
pub fn hdr_output(&self) -> bool {
|
||||
self.hdr
|
||||
&& matches!(
|
||||
(self.format, self.bit_depth),
|
||||
(ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png, _) | (ExportFormat::Tiff, Some(32))
|
||||
(ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32))
|
||||
)
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR; HDR
|
||||
/// PNG: Rec. 2020).
|
||||
/// The colour space the file is actually written in (AVIF: sRGB; AVIF, PNG and JPEG XL HDR:
|
||||
/// Rec. 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder
|
||||
/// can declare without a profile).
|
||||
pub fn effective_space(&self) -> OutputSpace {
|
||||
match self.format {
|
||||
ExportFormat::Avif | ExportFormat::Png if self.hdr => OutputSpace::Rec2020,
|
||||
ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020,
|
||||
ExportFormat::Avif => OutputSpace::Srgb,
|
||||
ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => {
|
||||
OutputSpace::Rec2020
|
||||
}
|
||||
_ => self.color_space,
|
||||
}
|
||||
}
|
||||
|
||||
/// The JPEG XL encoder settings for these options, writing samples in `space`.
|
||||
fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions {
|
||||
let colour = match space {
|
||||
_ if hdr => JxlColour::Rec2020Pq,
|
||||
OutputSpace::DisplayP3 => JxlColour::DisplayP3,
|
||||
OutputSpace::Rec2020 => JxlColour::Rec2020,
|
||||
// lossless files carry the ICC profile; lossy ones never get here (`effective_space`)
|
||||
OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb,
|
||||
};
|
||||
JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour }
|
||||
}
|
||||
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
/// extension is kept for [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
|
||||
@@ -1269,6 +1339,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
ExportFormat::Psd => encode::encode_psd(&e, &meta),
|
||||
ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta),
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta),
|
||||
f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
@@ -1382,6 +1453,10 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
|
||||
}
|
||||
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
(ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => {
|
||||
lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta)
|
||||
}
|
||||
(ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta),
|
||||
(f, _) => return Err(format!("{f:?} export is 8-bit only")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
@@ -1667,6 +1742,104 @@ pub struct Destination {
|
||||
pub exact: Option<String>,
|
||||
}
|
||||
|
||||
/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or
|
||||
/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by
|
||||
/// [`crate::Session::after_export`] before the export starts, so an export asking for something
|
||||
/// this session can't do fails before writing anything; for Photos it holds the import slot
|
||||
/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when
|
||||
/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its
|
||||
/// worker.
|
||||
#[derive(Default)]
|
||||
pub struct AfterExport {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos: Option<crate::apple_photos::Reservation>,
|
||||
}
|
||||
|
||||
impl AfterExport {
|
||||
/// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving
|
||||
/// the import slot of `imports` now. (Front ends get this from
|
||||
/// [`crate::Session::after_export`], which checks the platform first.)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result<Self, String> {
|
||||
Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) })
|
||||
}
|
||||
|
||||
/// Nothing to do.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.apple_photos.is_none()
|
||||
}
|
||||
|
||||
/// Nothing to do.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn is_none(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written
|
||||
/// count), waiting for Photos: for a worker thread (the app's background export) or a caller
|
||||
/// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the
|
||||
/// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false,
|
||||
/// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the
|
||||
/// export was cancelled or wrote nothing). Never fails the export: its files are written
|
||||
/// either way.
|
||||
pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option<serde_json::Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = self.apple_photos {
|
||||
return Some(r.run(files, cancelled));
|
||||
}
|
||||
let _ = (files, cancelled);
|
||||
None
|
||||
}
|
||||
|
||||
/// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the
|
||||
/// outcome is the running job (`{job, running: true, requested, album}`; its result comes
|
||||
/// from `export.photosImports`, and the desktop app announces it). For a caller on the UI
|
||||
/// thread, which must not wait minutes for Photos.
|
||||
pub fn start(self, files: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = self.apple_photos {
|
||||
return Some(r.start(files));
|
||||
}
|
||||
let _ = files;
|
||||
None
|
||||
}
|
||||
|
||||
/// [`AfterExport::start`] where the session answers on a UI thread
|
||||
/// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`].
|
||||
pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if s.apple_photos_background {
|
||||
return self.start(files);
|
||||
}
|
||||
let _ = s;
|
||||
self.run(files, false)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// What an export with `o` does after writing its files; an error, before anything is written,
|
||||
/// when it asks for Apple Photos and this platform or session has no way to reach it, or
|
||||
/// another import is reserved or running. For Photos the import slot is reserved from now on.
|
||||
pub fn after_export(&self, o: &ExportOptions) -> Result<AfterExport, String> {
|
||||
if !o.add_to_photos {
|
||||
return Ok(AfterExport::default());
|
||||
}
|
||||
if let Some(why) = apple_photos_unsupported() {
|
||||
return Err(why.into());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?;
|
||||
let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?;
|
||||
after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone()));
|
||||
Ok(after)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err("Apple Photos is only available on macOS".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
|
||||
pub fn export_batch(
|
||||
session: &mut crate::Session,
|
||||
@@ -2664,6 +2837,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_options() {
|
||||
assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl));
|
||||
let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap();
|
||||
assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true));
|
||||
assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings");
|
||||
assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err());
|
||||
assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err());
|
||||
let space = |lossless, color_space, hdr| {
|
||||
ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space()
|
||||
};
|
||||
assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3);
|
||||
assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum");
|
||||
assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile");
|
||||
assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020);
|
||||
let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() };
|
||||
assert!(o.hdr_output());
|
||||
assert_eq!(o.effective_depth(), OutputDepth::F32Hdr);
|
||||
assert_eq!(ExportFormat::Jxl.extension(), "jxl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_every_space_round_trips() {
|
||||
// wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files
|
||||
for (lossless, space) in [
|
||||
(false, OutputSpace::Srgb),
|
||||
(false, OutputSpace::DisplayP3),
|
||||
(false, OutputSpace::Rec2020),
|
||||
(true, OutputSpace::DisplayP3),
|
||||
(true, OutputSpace::AdobeRgb),
|
||||
(true, OutputSpace::ProPhoto),
|
||||
] {
|
||||
let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() };
|
||||
let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() };
|
||||
let img = p3_green(space);
|
||||
let bytes = encode_image(&img, &o).unwrap();
|
||||
assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl));
|
||||
let (got, _) = decoded_in_srgb(&bytes);
|
||||
let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap());
|
||||
for c in 0..3 {
|
||||
assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}");
|
||||
}
|
||||
if space != OutputSpace::Srgb {
|
||||
assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_sixteen_bit() {
|
||||
let o = ExportOptions {
|
||||
format: ExportFormat::Jxl,
|
||||
bit_depth: Some(16),
|
||||
jxl_lossless: true,
|
||||
color_space: OutputSpace::DisplayP3,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(o.effective_depth(), OutputDepth::U16);
|
||||
let (levels8, _) = distinct_levels(
|
||||
&encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(),
|
||||
);
|
||||
let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap());
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
assert!(levels > 10 * levels8, "{levels} vs {levels8}");
|
||||
// lossy 16-bit encodes too
|
||||
let lossy = ExportOptions { jxl_lossless: false, ..o };
|
||||
assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_depth_options_per_format() {
|
||||
let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth();
|
||||
@@ -2674,7 +2916,9 @@ mod tests {
|
||||
assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16);
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16));
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None);
|
||||
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] {
|
||||
assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16);
|
||||
assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8);
|
||||
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] {
|
||||
let first = ExportOptions::bit_depths(f)[0].0;
|
||||
let def = d(f, None);
|
||||
assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default");
|
||||
@@ -2743,6 +2987,22 @@ mod tests {
|
||||
j[k] = v;
|
||||
}
|
||||
assert_eq!(ExportOptions::from_params(&j).unwrap(), full);
|
||||
// Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is
|
||||
assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false"));
|
||||
assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string"));
|
||||
assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL"));
|
||||
assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`"));
|
||||
let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() };
|
||||
assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album);
|
||||
let on = ExportOptions { add_to_photos: true, ..album };
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on);
|
||||
} else {
|
||||
assert!(err(on.to_json()).contains("Apple Photos is only available on macOS"));
|
||||
// stored settings (a preset or Export with Previous made on a Mac) still read
|
||||
assert!(ExportOptions::from_json(&on.to_json()).add_to_photos);
|
||||
}
|
||||
assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip");
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default());
|
||||
// null is "not given", as a dropped key is
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
|
||||
+185
-3
@@ -680,17 +680,144 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
|
||||
|
||||
/// Filesystem-backed embedded-preview hook (native).
|
||||
///
|
||||
/// Reads the whole file to find the preview, so it holds the memory gate for twice the file's size (the file and the
|
||||
/// decoded preview): background work (the face scan) waits for room, interactive work is counted and never waits.
|
||||
/// The quick path reads the file's header and its embedded preview and nothing else (see
|
||||
/// [`preview_window`]); a format whose preview the header cannot point at falls back to the
|
||||
/// whole-file path, which is what every format used to cost. The memory gate still counts the whole
|
||||
/// file, which is what the decode of one can hold.
|
||||
pub fn fs_preview_loader() -> PreviewLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
|
||||
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
|
||||
return Some(image);
|
||||
}
|
||||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||||
})
|
||||
}
|
||||
|
||||
/// What [`preview_window`] read from a file, so a caller can see what a preview cost.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PreviewRead {
|
||||
/// Bytes read.
|
||||
pub read: u64,
|
||||
/// The file's size.
|
||||
pub total: u64,
|
||||
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
|
||||
/// every preview cost before the header path existed).
|
||||
pub header_path: bool,
|
||||
}
|
||||
|
||||
impl PreviewRead {
|
||||
/// The share of the file that was read, `0.0`..=`1.0`.
|
||||
pub fn fraction(self) -> f64 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.read as f64 / self.total as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
|
||||
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
|
||||
/// which is the caller's cue to read the file whole.
|
||||
///
|
||||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
|
||||
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
|
||||
/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview`
|
||||
/// still needs the whole container, so the header comes back with the window; for a preview taken
|
||||
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
|
||||
/// whole-file fallback stays in charge.
|
||||
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
|
||||
let mut prefix = read_prefix(&mut file, path, total)?;
|
||||
|
||||
// What the header itself declares, before the container reader is asked: it clamps a slice to
|
||||
// the buffer it was given, so a prefix that stops inside the declared preview yields a
|
||||
// self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF:
|
||||
// a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent
|
||||
// first costs nothing — it lives in the first bytes of every format covered here.
|
||||
if let Some(extent) = raf_preview_extent(&prefix)
|
||||
&& extent > prefix.len() as u64
|
||||
{
|
||||
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
|
||||
}
|
||||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) };
|
||||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||||
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
|
||||
// When the prefix already covers the range (the usual case: the prefix was grown to the declared
|
||||
// extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview.
|
||||
let prefix_len = prefix.len() as u64;
|
||||
let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec);
|
||||
let (window, bytes_read) = match from_prefix {
|
||||
Some(window) => (window, prefix_len),
|
||||
None => {
|
||||
let mut window = vec![0u8; len];
|
||||
file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?;
|
||||
(window, prefix_len + len as u64)
|
||||
}
|
||||
};
|
||||
|
||||
// A lying pointer must not cost the user their preview: hand the caller the whole-file fallback.
|
||||
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true };
|
||||
// On a network share the bytes are the wait, so say what this preview actually cost. A window
|
||||
// taken from the prefix cost nothing beyond the prefix itself.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len());
|
||||
}
|
||||
Ok(Some((prefix, window, read)))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
|
||||
let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX));
|
||||
read_prefix_at(file, path, len as u64)
|
||||
}
|
||||
|
||||
/// The file's first `len` bytes, read from the start.
|
||||
fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result<Vec<u8>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?;
|
||||
let mut prefix = vec![0u8; len];
|
||||
file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?;
|
||||
Ok(prefix)
|
||||
}
|
||||
|
||||
/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at
|
||||
/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here
|
||||
/// without the rest of the file so the prefix can be made big enough first.
|
||||
fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
|
||||
if !prefix.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
return None;
|
||||
}
|
||||
let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from);
|
||||
let (offset, len) = (u32_at(84)?, u32_at(88)?);
|
||||
(offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten()
|
||||
}
|
||||
|
||||
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
|
||||
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
|
||||
/// `None` when the bytes are not a decodable image.
|
||||
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
}
|
||||
// The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does
|
||||
Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation)))
|
||||
}
|
||||
|
||||
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
|
||||
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
|
||||
pub fn fs_pair_loader() -> PairLoader {
|
||||
@@ -735,13 +862,18 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless).
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
|
||||
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
|
||||
pub fn with_fs(mut self) -> Self {
|
||||
let (l, p) = fs_hooks();
|
||||
self.media.file_loader = Some(l);
|
||||
self.media.file_probe = Some(p);
|
||||
self.media.preview_loader = Some(fs_preview_loader());
|
||||
self.media.denoise.loader = Some(fs_pair_loader());
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.apple_photos = Some(crate::apple_photos::osascript());
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -803,6 +935,56 @@ mod tests {
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||||
}
|
||||
|
||||
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
|
||||
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
|
||||
let head = 160;
|
||||
let mut f = vec![0u8; head];
|
||||
f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW ");
|
||||
f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes());
|
||||
f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(jpeg);
|
||||
f.extend(std::iter::repeat_n(0x33u8, tail));
|
||||
f
|
||||
}
|
||||
|
||||
/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a
|
||||
/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten
|
||||
/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the
|
||||
/// quick path produces must equal the whole-file path's, exactly.
|
||||
#[test]
|
||||
fn preview_loading_reads_only_the_header_and_the_jpeg() {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let file = raf_with_embedded_jpeg(&jpeg, 200_000);
|
||||
let dir = crate::tests_xmp::temp_dir("preview-window");
|
||||
let path = dir.join("dscf0001.raf");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||||
assert!(read.header_path, "the header path is what answered");
|
||||
assert_eq!(read.total, file.len() as u64);
|
||||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
|
||||
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The quick path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
|
||||
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
|
||||
let plain = dir.join("plain.jpg");
|
||||
std::fs::write(&plain, &jpeg).unwrap();
|
||||
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
|
||||
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||||
/// file or one that isn't an image.
|
||||
|
||||
@@ -210,6 +210,48 @@ pub struct ImportReport {
|
||||
pub scanned: usize,
|
||||
/// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP).
|
||||
pub sidecars: usize,
|
||||
/// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<ImportWarning>,
|
||||
}
|
||||
|
||||
/// A structured diagnostic from foreign XMP develop settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportWarningCode {
|
||||
/// A named camera profile was found but cannot be applied as Lightroom does.
|
||||
CameraProfile,
|
||||
/// Lightroom geometry data was found but its saved transform is not retained.
|
||||
UprightGeometry,
|
||||
/// One or more other XMP develop fields have no LightCraft mapping.
|
||||
UnmappedXmp,
|
||||
}
|
||||
|
||||
/// The saved Lightroom Upright mode associated with a foreign geometry transform.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportUprightMode {
|
||||
Off,
|
||||
Auto,
|
||||
Level,
|
||||
Vertical,
|
||||
Full,
|
||||
Guided,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One actionable XMP import diagnostic. `path` is filled when its photo is committed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportWarning {
|
||||
pub path: String,
|
||||
pub code: ImportWarningCode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<ImportUprightMode>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fields: Vec<String>,
|
||||
}
|
||||
|
||||
/// A file moved by an import.
|
||||
@@ -1274,6 +1316,10 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| {
|
||||
warning.path = path.clone();
|
||||
warning
|
||||
}));
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
extern crate lightcraft_denoise_core as lightcraft_denoise;
|
||||
|
||||
pub mod activity;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod apple_photos;
|
||||
pub mod availability;
|
||||
pub(crate) mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
@@ -293,6 +295,20 @@ pub struct Session {
|
||||
/// Untouched Local records of folders not browsed for this many days are forgotten when the
|
||||
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
|
||||
pub forget_local_days: u32,
|
||||
/// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by
|
||||
/// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests
|
||||
/// put a fake here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos: Option<apple_photos::Runner>,
|
||||
/// This session's Apple Photos imports, running or finished (`export.photosImports`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos_imports: apple_photos::Imports,
|
||||
/// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`,
|
||||
/// `app.export` without `background`): set by the desktop app, which answers control requests
|
||||
/// on its UI thread. Off (the default), they wait for Photos, so a one-shot process
|
||||
/// (lightcraft-cli, a script) never ends in the middle of one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos_background: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -374,6 +390,12 @@ impl Session {
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos_imports: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos_background: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 60;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 61;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -846,7 +846,8 @@ impl RenderJob {
|
||||
(Some(d), Some(_)) => {
|
||||
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
|
||||
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
|
||||
if let Some(h) = h {
|
||||
// (an HDR edit's HDR histogram stays)
|
||||
if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
srgb = s;
|
||||
@@ -909,6 +910,22 @@ pub fn develop_reporting(
|
||||
req: &RenderRequest,
|
||||
stages: Option<&StageCache>,
|
||||
gpu: bool,
|
||||
) -> (Rendered, Option<String>) {
|
||||
let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu);
|
||||
// an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead
|
||||
if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
(rendered, fallback)
|
||||
}
|
||||
|
||||
fn develop_image(
|
||||
src: &Arc<Rgb32f>,
|
||||
info: &SourceInfo,
|
||||
s: &DevelopSettings,
|
||||
req: &RenderRequest,
|
||||
stages: Option<&StageCache>,
|
||||
gpu: bool,
|
||||
) -> (Rendered, Option<String>) {
|
||||
// LUT profiles have no GPU stage: they render on the CPU
|
||||
let fallback = if gpu
|
||||
@@ -1295,6 +1312,23 @@ impl crate::Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor
|
||||
/// decode behind it.
|
||||
///
|
||||
/// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws
|
||||
/// likewise start from the embedded preview"), and then paid for a render of the raw to replace
|
||||
/// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px
|
||||
/// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is
|
||||
/// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind
|
||||
/// it delayed the render the user was waiting for.
|
||||
///
|
||||
/// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what
|
||||
/// says the photo still looks the way it did at import. A photo the user edits re-renders
|
||||
/// through the ordinary path, because its thumbnail key (settings hash) changes.
|
||||
pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool {
|
||||
self.embedded_of(p).is_some()
|
||||
}
|
||||
|
||||
/// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the
|
||||
/// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the
|
||||
/// raw. `None` for anything else, and where the host installed no loader.
|
||||
|
||||
@@ -104,6 +104,9 @@ pub struct SidecarData {
|
||||
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
|
||||
pub captured: Option<String>,
|
||||
pub develop: Option<DevelopPatch>,
|
||||
/// Actionable diagnostics for foreign develop settings that were not fully carried over.
|
||||
/// These are surfaced in the photo import report and never persisted in the catalog.
|
||||
pub warnings: Vec<crate::import::ImportWarning>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -165,7 +168,65 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu
|
||||
out.develop = match full {
|
||||
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => {
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT);
|
||||
// Lightroom's default profiles are named by nearly every sidecar; our own starting look stands
|
||||
// in for them, so only another profile is worth a warning
|
||||
let profile = crate::crs::camera_profile_name(&d.properties)
|
||||
.filter(|name| !matches!(name.trim(), "Adobe Standard" | "Adobe Color"))
|
||||
.map(str::to_string);
|
||||
if let Some(name) = profile {
|
||||
out.warnings.push(crate::import::ImportWarning {
|
||||
path: String::new(),
|
||||
code: crate::import::ImportWarningCode::CameraProfile,
|
||||
profile: Some(name),
|
||||
mode: None,
|
||||
fields: Vec::new(),
|
||||
});
|
||||
}
|
||||
let upright = crate::crs::has_saved_upright_transform(&d.properties);
|
||||
if upright {
|
||||
let mode = d
|
||||
.properties
|
||||
.get("crs:PerspectiveUpright")
|
||||
.and_then(|values| values.first())
|
||||
.map(String::as_str)
|
||||
.map(|mode| match mode {
|
||||
"0" => crate::import::ImportUprightMode::Off,
|
||||
"1" => crate::import::ImportUprightMode::Auto,
|
||||
"2" => crate::import::ImportUprightMode::Level,
|
||||
"3" => crate::import::ImportUprightMode::Vertical,
|
||||
"4" => crate::import::ImportUprightMode::Full,
|
||||
"5" => crate::import::ImportUprightMode::Guided,
|
||||
_ => crate::import::ImportUprightMode::Unknown,
|
||||
})
|
||||
.unwrap_or(crate::import::ImportUprightMode::Unknown);
|
||||
out.warnings.push(crate::import::ImportWarning {
|
||||
path: String::new(),
|
||||
code: crate::import::ImportWarningCode::UprightGeometry,
|
||||
profile: None,
|
||||
mode: Some(mode),
|
||||
fields: Vec::new(),
|
||||
});
|
||||
}
|
||||
let other: Vec<&str> = unmapped
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|name| {
|
||||
*name != "CameraProfile"
|
||||
&& !name.starts_with("UprightTransform_")
|
||||
&& !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion")
|
||||
})
|
||||
.collect();
|
||||
if !other.is_empty() {
|
||||
out.warnings.push(crate::import::ImportWarning {
|
||||
path: String::new(),
|
||||
code: crate::import::ImportWarningCode::UnmappedXmp,
|
||||
profile: None,
|
||||
mode: None,
|
||||
fields: other.into_iter().map(str::to_string).collect(),
|
||||
});
|
||||
}
|
||||
Some(DevelopPatch::Partial(partial))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -517,11 +578,21 @@ impl Session {
|
||||
|
||||
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
|
||||
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
|
||||
self.read_sidecar_op_with_warnings(id).map(|result| result.map(|(op, from, _)| (op, from)))
|
||||
}
|
||||
|
||||
/// Like [`Session::read_sidecar_op`], retaining transient diagnostics for the command report.
|
||||
pub fn read_sidecar_op_with_warnings(&self, id: PhotoId) -> Result<Option<(Op, PathBuf, Vec<crate::import::ImportWarning>)>> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
||||
Ok(Some((self.sidecar_op(id, sc)?, from)))
|
||||
let mut warnings = sc.warnings.clone();
|
||||
for warning in &mut warnings {
|
||||
warning.path = orig.to_string();
|
||||
}
|
||||
let op = self.sidecar_op(id, sc)?;
|
||||
Ok(Some((op, from, warnings)))
|
||||
}
|
||||
|
||||
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
|
||||
|
||||
@@ -1163,3 +1163,18 @@ fn undo_and_redo_show_the_photo_they_change() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.selection, before, "a step over several photos keeps the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_edits_render_with_their_hdr_histogram() {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let plain = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
|
||||
assert!(plain.histogram.hdr.is_none(), "SDR edits: the image's histogram");
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let r = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
|
||||
let bins = r.histogram.hdr.expect("an HDR edit's histogram");
|
||||
assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "a fixed scale");
|
||||
assert!(r.histogram.above_sdr() > 0.0);
|
||||
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
|
||||
}
|
||||
@@ -542,3 +542,320 @@ fn hdr_png_export_is_16_bit_pq_rec2020() {
|
||||
let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap();
|
||||
assert!(!plain.bytes.windows(4).any(|w| w == b"cICP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_jxl_export_is_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "jxl", "hdr": true, "quality": 90}));
|
||||
assert!(o.hdr_output());
|
||||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let encoding = |b: &[u8]| {
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
(format!("{:?}", m.colour_encoding), m.bit_depth.bits_per_sample())
|
||||
};
|
||||
let (c, bits) = encoding(&hdr.bytes);
|
||||
assert!(c.contains("Bt2100") && c.contains("Pq"), "{c}");
|
||||
assert_eq!(bits, 16);
|
||||
// a photo without an HDR edit exports as an ordinary SDR file
|
||||
let (c, _) = encoding(&sdr.bytes);
|
||||
assert!(!c.contains("Pq"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_export_writes_metadata() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 120, "format": "jxl", "quality": 80}));
|
||||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
assert_eq!(d.width.max(d.height), 120);
|
||||
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("Golden horizon")), "title in the XMP box");
|
||||
assert!(d.exif.is_some(), "EXIF box");
|
||||
}
|
||||
|
||||
/// Issue #236: Add to Apple Photos after an export. A fake runner stands in for osascript: tests
|
||||
/// never start it, nor Photos.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod apple_photos {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::disk_batch;
|
||||
use crate::Session;
|
||||
use crate::apple_photos::{Imports, MARKER, Output, Runner};
|
||||
use crate::export::{AfterExport, Destination, ExportOptions};
|
||||
use crate::tests_xmp::temp_dir;
|
||||
|
||||
type Calls = Arc<Mutex<Vec<Vec<String>>>>;
|
||||
|
||||
/// Records the arguments; Photos "imports" the first `keep` files (all: `usize::MAX`).
|
||||
fn fake(keep: usize) -> (Runner, Calls) {
|
||||
let calls: Calls = Arc::default();
|
||||
let c = calls.clone();
|
||||
let r: Runner = Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
|
||||
let ids: Vec<String> = (0..files.min(keep)).map(|i| format!("ID-{i}/L0/001")).collect();
|
||||
Ok(Output { status: Some(0), stdout: ids.join("\n") + "\n", stderr: String::new() })
|
||||
});
|
||||
(r, calls)
|
||||
}
|
||||
|
||||
/// The paths after the album: the files Photos was asked to import.
|
||||
fn imported_paths(args: &[String]) -> Vec<String> {
|
||||
args[args.iter().position(|a| a == MARKER).unwrap() + 2..].to_vec()
|
||||
}
|
||||
|
||||
fn export_two(s: &mut Session, tag: &str) -> Vec<Value> {
|
||||
let dir = temp_dir(tag);
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 48, "naming": "{name}-{seq}"}));
|
||||
let to = Destination { dir: dir.to_string_lossy().into(), exact: None };
|
||||
let mut files = Vec::new();
|
||||
for id in ids {
|
||||
files.extend(disk_batch(s, id, &o, &to).unwrap());
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_files_go_to_photos_in_order() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-flow");
|
||||
let written: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(written.len(), 2);
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
let imports = Imports::default();
|
||||
let after = AfterExport::apple_photos(runner.clone(), " Trip \"2026\"\n ", &imports).unwrap();
|
||||
// a skipped file and a failed photo are not sent
|
||||
let mut results = files.clone();
|
||||
results.insert(1, json!({"skipped": "/elsewhere/x.jpg"}));
|
||||
results.push(json!({"photo": 9, "file": "y", "error": "no source"}));
|
||||
let out = after.run(&results, false).unwrap();
|
||||
assert_eq!(out["imported"], 2, "{out}");
|
||||
assert_eq!(out["requested"], 2);
|
||||
assert_eq!(out["ids"], json!(["ID-0/L0/001", "ID-1/L0/001"]));
|
||||
assert_eq!(out["album"], "Trip \"2026\"");
|
||||
assert!(out.get("warning").is_none());
|
||||
let args = calls.lock().unwrap()[0].clone();
|
||||
assert_eq!(imported_paths(&args), written, "the files written, in order, as whole arguments");
|
||||
assert!(written.iter().all(|p| std::path::Path::new(p).is_absolute()));
|
||||
assert_eq!(args[args.iter().position(|a| a == MARKER).unwrap() + 1], "Trip \"2026\"");
|
||||
// a cancelled export adds nothing
|
||||
let out = AfterExport::apple_photos(runner.clone(), "", &imports).unwrap().run(&files, true).unwrap();
|
||||
assert!(out["skipped"].as_str().unwrap().contains("cancelled"));
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
// Photos took one of the two: said so, not an error
|
||||
let (runner, _) = fake(1);
|
||||
let out = AfterExport::apple_photos(runner, "", &Imports::default()).unwrap().run(&files, false).unwrap();
|
||||
assert_eq!((out["imported"].as_u64(), out["requested"].as_u64()), (Some(1), Some(2)));
|
||||
assert!(out["warning"].as_str().unwrap().contains("1 of 2"), "{out}");
|
||||
assert!(out["album"].is_null());
|
||||
// permission refused: the outcome says where to allow it; the files stay written
|
||||
let denied: Runner = Arc::new(|_: &[String], _| {
|
||||
Ok(Output {
|
||||
status: Some(1),
|
||||
stdout: String::new(),
|
||||
stderr: "execution error: Not authorized to send Apple events to Photos. (-1743)".into(),
|
||||
})
|
||||
});
|
||||
let out = AfterExport::apple_photos(denied, "", &Imports::default()).unwrap().run(&files, false).unwrap();
|
||||
assert!(out["error"].as_str().unwrap().contains("Privacy & Security › Automation"), "{out}");
|
||||
assert!(written.iter().all(|p| std::path::Path::new(p).is_file()));
|
||||
// nothing asked: nothing done
|
||||
assert!(AfterExport::default().is_none());
|
||||
assert_eq!(AfterExport::default().run(&files, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_is_refused_before_anything_is_written_when_it_cannot_be_reached() {
|
||||
let mut s = Session::with_demo();
|
||||
assert!(s.apple_photos.is_none(), "only with_fs installs the runner");
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
let e = s.after_export(&on).err().unwrap();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(e.contains("isn't connected in this session"), "{e}");
|
||||
} else {
|
||||
assert_eq!(e, "Apple Photos is only available on macOS");
|
||||
}
|
||||
assert!(s.after_export(&ExportOptions::default()).unwrap().is_none());
|
||||
// with a runner: macOS adds, other platforms still refuse
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner);
|
||||
let after = s.after_export(&on);
|
||||
assert_eq!(after.is_ok(), cfg!(target_os = "macos"), "{:?}", after.as_ref().err());
|
||||
match after {
|
||||
Ok(after) => {
|
||||
let files = export_two(&mut s, "photos-session");
|
||||
assert_eq!(after.run(&files, false).unwrap()["imported"], 2);
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
Err(e) => assert!(e.contains("only available on macOS"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_add_to_photos_command_needs_a_runner_and_checks_its_input() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-cmd");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
let info = |s: &Session| s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap();
|
||||
// no runner: listed, disabled, with the reason
|
||||
let i = info(&s);
|
||||
assert!(!i.enabled);
|
||||
let why = i.disabled_reason.unwrap();
|
||||
assert!(why.contains(if cfg!(target_os = "macos") { "isn't connected" } else { "only available on macOS" }), "{why}");
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("Apple Photos"), "{e}");
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner);
|
||||
assert!(info(&s).enabled);
|
||||
// no window: it waits for Photos by default
|
||||
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Läufe/日本 \\ \"q\""})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["running"].as_bool(), r["job"].as_u64()), (Some(2), Some(false), Some(1)), "{r}");
|
||||
assert_eq!(r["album"], "Läufe/日本 \\ \"q\"");
|
||||
assert_eq!(imported_paths(&calls.lock().unwrap()[0]), paths);
|
||||
// bad input never reaches the runner (paths from the temp folder: absolute on every platform)
|
||||
let missing = std::env::temp_dir().join("lc-photos-nonexistent").join("a.jpg").to_string_lossy().into_owned();
|
||||
for (p, msg) in [
|
||||
(json!({}), "missing `paths`"),
|
||||
(json!({"paths": "a.jpg"}), "missing `paths`"),
|
||||
(json!({"paths": [1]}), "array of file paths"),
|
||||
(json!({"paths": []}), "no files"),
|
||||
(json!({"paths": ["relative.jpg"]}), "absolute"),
|
||||
(json!({"paths": [missing]}), "no such file"),
|
||||
(json!({"paths": paths, "album": 3}), "`album` must be a string"),
|
||||
(json!({"paths": paths, "wait": "yes"}), "`wait` must be true or false"),
|
||||
(json!({"paths": paths, "albm": "x"}), "unknown parameter `albm`"),
|
||||
] {
|
||||
let e = s.execute("export.addToPhotos", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
// not journaled: replaying actions must not import again
|
||||
assert!(!s.journal.iter().any(|(id, _)| id == "export.addToPhotos"));
|
||||
}
|
||||
|
||||
/// The desktop app answers control requests on its UI thread: `export.addToPhotos` must not
|
||||
/// wait there for Photos (minutes, or a permission prompt). It returns the running job; the
|
||||
/// outcome comes from `export.photosImports`; a second import, or an export asking for Photos,
|
||||
/// is refused until it ends, so a retry never adds the files twice.
|
||||
#[test]
|
||||
fn a_slow_photos_import_runs_in_the_background() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-slow");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = Mutex::new(wait);
|
||||
let runner: Runner = Arc::new(move |args: &[String], _| {
|
||||
// a regression (waiting on the caller's thread) fails after 20 s instead of hanging
|
||||
let _ = wait.lock().unwrap().recv_timeout(std::time::Duration::from_secs(20));
|
||||
let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
|
||||
Ok(Output { status: Some(0), stdout: (0..n).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() })
|
||||
});
|
||||
s.apple_photos = Some(runner);
|
||||
s.apple_photos_background = true; // as in the desktop app
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Slow"})).unwrap();
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "answered without waiting for Photos");
|
||||
assert_eq!(r, json!({"job": 1, "running": true, "requested": 2, "album": "Slow"}));
|
||||
let status = |s: &mut Session| s.execute("export.photosImports", &json!({"job": 1})).unwrap();
|
||||
assert_eq!(status(&mut s)["running"], true);
|
||||
// a retry is refused, and says why
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}");
|
||||
if cfg!(target_os = "macos") {
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
assert!(s.after_export(&on).err().unwrap().contains("still adding"), "refused before writing anything");
|
||||
}
|
||||
go.send(()).unwrap();
|
||||
while status(&mut s)["running"] == true {
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the import finishes");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let done = status(&mut s);
|
||||
assert_eq!((done["imported"].as_u64(), done["album"].as_str()), (Some(2), Some("Slow")), "{done}");
|
||||
let all = s.execute("export.photosImports", &json!({})).unwrap();
|
||||
assert_eq!(all["imports"].as_array().map(Vec::len), Some(1));
|
||||
assert!(s.execute("export.photosImports", &json!({"job": 7})).unwrap_err().to_string().contains("no import 7"));
|
||||
// the window's thread never waits for Photos: `wait: true` is refused here, before any import
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths, "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("isn't available in the desktop app"), "{e}");
|
||||
// finished: the next one may start
|
||||
go.send(()).unwrap();
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["job"], 2);
|
||||
}
|
||||
|
||||
/// Review of #236: an export adding to Photos reserves the import before it writes its files.
|
||||
/// While it renders, `export.addToPhotos` is refused, naming the export's job, instead of
|
||||
/// taking Photos and leaving the export to fail its Photos step after writing.
|
||||
#[test]
|
||||
fn an_export_holds_photos_from_before_it_writes() {
|
||||
let mut s = Session::with_demo();
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner.clone());
|
||||
let elsewhere = export_two(&mut s, "photos-hold-other");
|
||||
let other: Vec<String> = elsewhere.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
// what Session::after_export does on macOS (it refuses other platforms first)
|
||||
let after = AfterExport::apple_photos(runner, "Held", &s.apple_photos_imports).unwrap();
|
||||
let job = s.execute("export.photosImports", &json!({"job": 1})).unwrap();
|
||||
assert_eq!((job["running"].as_bool(), job["exporting"].as_bool()), (Some(true), Some(true)), "{job}");
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": other})).unwrap_err().to_string();
|
||||
assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}");
|
||||
if cfg!(target_os = "macos") {
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
assert!(s.after_export(&on).err().unwrap().contains("(import 1)"), "a second export is refused before writing");
|
||||
}
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
// the export writes its files and hands them over: imported under its own job
|
||||
let files = export_two(&mut s, "photos-hold");
|
||||
let out = after.run(&files, false).unwrap();
|
||||
assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out["album"].as_str()), (Some(1), Some(2), Some("Held")), "{out}");
|
||||
// then the slot is free
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 2);
|
||||
// an export that fails (its AfterExport dropped) frees it too
|
||||
drop(AfterExport::apple_photos(fake(usize::MAX).0, "", &s.apple_photos_imports).unwrap());
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 4);
|
||||
}
|
||||
|
||||
/// Review of #236: a runner that panics while the command or an export waits for it is that
|
||||
/// import's error; the slot is freed, so the next import isn't refused as "still running"
|
||||
/// and a process ending doesn't wait for it.
|
||||
#[test]
|
||||
fn a_panicking_runner_never_holds_photos() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-panic");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
s.apple_photos = Some(Arc::new(|_: &[String], _| panic!("runner exploded")));
|
||||
// the command (no window: it waits)
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("stopped unexpectedly"), "{e}");
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert_eq!(s.execute("export.photosImports", &json!({"job": 1})).unwrap()["running"], false);
|
||||
// an export's reservation
|
||||
let runner = s.apple_photos.clone().unwrap();
|
||||
let out = AfterExport::apple_photos(runner, "", &s.apple_photos_imports).unwrap().run(&files, false).unwrap();
|
||||
assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}");
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert!(!s.wait_for_photos_imports(), "nothing left to wait for");
|
||||
// and the next import runs
|
||||
let (good, _) = fake(usize::MAX);
|
||||
s.apple_photos = Some(good);
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["imported"], 2);
|
||||
}
|
||||
|
||||
/// The desktop app, lightcraft-cli and the MCP server get the real runner from `with_fs`, on
|
||||
/// macOS only. (Installed, never run here.)
|
||||
#[test]
|
||||
fn with_fs_installs_the_runner_on_macos_only() {
|
||||
let s = Session::new().with_fs();
|
||||
assert_eq!(s.apple_photos.is_some(), cfg!(target_os = "macos"));
|
||||
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
|
||||
}
|
||||
}
|
||||
@@ -395,11 +395,17 @@ fn auto_import_watched_folder() {
|
||||
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "album": "Tethered"})).unwrap();
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
write_png(&dir.join("one.png"), 1);
|
||||
std::fs::write(
|
||||
dir.join("one.xmp"),
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:CameraProfile="Color" crs:Exposure2012="0.5"/></rdf:RDF></x:xmpmeta>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), "not a photo").unwrap();
|
||||
// first sight: wait (it may still be copying)
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
let r = s.execute("library.autoImportScan", &json!({})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Color"), "{r}");
|
||||
let album = s.catalog.albums().find(|a| a.name == "Tethered").expect("album").id;
|
||||
assert_eq!(s.catalog.album_count(album), 1);
|
||||
assert!(s.selection.ids.is_empty(), "arrivals don't take the selection");
|
||||
|
||||
@@ -171,6 +171,136 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
||||
std::fs::remove_file(src.join("harbour.xmp")).unwrap();
|
||||
let r = s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar
|
||||
/// and our own starting look stands in for them: importing one gives no camera-profile warning.
|
||||
#[test]
|
||||
fn default_lightroom_profiles_are_not_reported() {
|
||||
let src = temp_dir("xmp-default-profile");
|
||||
for (i, name) in ["Adobe Standard", "Adobe Color"].into_iter().enumerate() {
|
||||
let photo = src.join(format!("p{i}.png"));
|
||||
write_png(&photo, 6);
|
||||
let xmp = format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:CameraProfile="{name}" crs:Exposure2012="0.5"/>
|
||||
</rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
std::fs::write(src.join(format!("p{i}.xmp")), xmp).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap();
|
||||
let none = Vec::new();
|
||||
let warnings = r["warnings"].as_array().unwrap_or(&none);
|
||||
assert!(!warnings.iter().any(|w| w["code"] == "cameraProfile"), "{name}: {r}");
|
||||
assert_eq!(s.catalog.photos().next().unwrap().develop.light.exposure, 0.5, "{name}: the edits still import");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Foreign profile names and saved Upright matrices stay visible without pretending their
|
||||
/// proprietary colour or coordinate data was imported. Diagnostics are per-photo report data;
|
||||
/// importing a file without XMP remains quiet, and the source packet is never rewritten.
|
||||
#[test]
|
||||
fn foreign_profile_and_upright_import_diagnostics_are_actionable() {
|
||||
let src = temp_dir("xmp-diagnostics");
|
||||
let photo = src.join("faithful.png");
|
||||
write_png(&photo, 6);
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:CameraProfile="Camera Faithful" crs:Exposure2012="1.68" crs:PerspectiveUpright="1"
|
||||
crs:UprightVersion="151388160" crs:UprightTransformCount="6"
|
||||
crs:UprightCenterNormX="0.554156" crs:UprightCenterNormY="0.529745"
|
||||
crs:UprightTransform_1="0.897233402,-0.013204641,-0.030578613,-0.096087436,0.953621255,0.003270197,-0.157075841,-0.043520282,1.000000000"/>
|
||||
</rdf:RDF></x:xmpmeta>"#;
|
||||
let sidecar = src.join("faithful.xmp");
|
||||
std::fs::write(&sidecar, xmp).unwrap();
|
||||
let before = std::fs::read(&sidecar).unwrap();
|
||||
let photo_path = photo.to_string_lossy().to_string();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap();
|
||||
let warnings = r["warnings"].as_array().unwrap();
|
||||
assert!(warnings.iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Camera Faithful"), "{r}");
|
||||
assert!(warnings.iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "auto"), "{r}");
|
||||
assert!(warnings.iter().all(|w| w.get("fields").is_none()), "{r}");
|
||||
let import_warnings: Vec<crate::import::ImportWarning> =
|
||||
warnings.iter().cloned().map(serde_json::from_value).collect::<std::result::Result<_, _>>().unwrap();
|
||||
let import_profile = import_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::CameraProfile).unwrap();
|
||||
assert_eq!(import_profile.path, photo_path);
|
||||
assert_eq!(import_profile.profile.as_deref(), Some("Camera Faithful"));
|
||||
assert!(import_profile.fields.is_empty());
|
||||
let import_upright = import_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::UprightGeometry).unwrap();
|
||||
assert_eq!(import_upright.path, photo_path);
|
||||
assert_eq!(import_upright.mode, Some(crate::import::ImportUprightMode::Auto));
|
||||
assert!(import_upright.fields.is_empty());
|
||||
let p = s.catalog.photos().next().unwrap().clone();
|
||||
assert_eq!(p.develop.profile.id, "lc.color");
|
||||
assert_eq!(p.develop.light.exposure, 1.68);
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), before, "import never rewrites foreign XMP");
|
||||
|
||||
// Metadata reread carries same structured diagnostics, with each saved Upright mode typed.
|
||||
let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap();
|
||||
let reread_wires = reread["warnings"].as_array().unwrap();
|
||||
assert!(reread_wires.iter().all(|w| w.get("fields").is_none()), "{reread}");
|
||||
let reread_warnings: Vec<crate::import::ImportWarning> =
|
||||
reread_wires.iter().cloned().map(serde_json::from_value).collect::<std::result::Result<_, _>>().unwrap();
|
||||
let reread_profile = reread_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::CameraProfile).unwrap();
|
||||
assert_eq!(reread_profile.path, photo_path);
|
||||
assert_eq!(reread_profile.profile.as_deref(), Some("Camera Faithful"));
|
||||
assert!(reread_profile.fields.is_empty());
|
||||
let reread_upright = reread_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::UprightGeometry).unwrap();
|
||||
assert_eq!(reread_upright.path, photo_path);
|
||||
assert_eq!(reread_upright.mode, Some(crate::import::ImportUprightMode::Auto));
|
||||
assert!(reread_upright.fields.is_empty());
|
||||
assert!(
|
||||
reread["warnings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|w| w["code"] == "cameraProfile" && w["profile"] == "Camera Faithful" && w["path"] == photo_path),
|
||||
"{reread}"
|
||||
);
|
||||
assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "auto"), "{reread}");
|
||||
for (source, mode) in [("0", "off"), ("2", "level"), ("3", "vertical"), ("4", "full"), ("5", "guided"), ("9", "unknown")] {
|
||||
let mode_xmp = xmp.replace("crs:PerspectiveUpright=\"1\"", &format!("crs:PerspectiveUpright=\"{source}\""));
|
||||
std::fs::write(&sidecar, mode_xmp).unwrap();
|
||||
let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap();
|
||||
assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == mode), "{source}: {reread}");
|
||||
}
|
||||
|
||||
// A foreign name colliding with a built-in profile label remains warning metadata only.
|
||||
let collision = src.join("collision.png");
|
||||
write_png(&collision, 8);
|
||||
std::fs::write(src.join("collision.xmp"), xmp.replace("Camera Faithful", "Color")).unwrap();
|
||||
let collision_report = s.execute("library.import", &json!({"paths": [collision.to_string_lossy()]})).unwrap();
|
||||
assert!(
|
||||
collision_report["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Color"),
|
||||
"{collision_report}"
|
||||
);
|
||||
let collision_photo = s.catalog.photos().find(|photo| photo.file_name == "collision.png").unwrap();
|
||||
assert_eq!(collision_photo.develop.profile.id, "lc.color");
|
||||
|
||||
let unmapped = crate::import::ImportWarning {
|
||||
path: photo_path.clone(),
|
||||
code: crate::import::ImportWarningCode::UnmappedXmp,
|
||||
profile: None,
|
||||
mode: None,
|
||||
fields: vec!["Tint".into()],
|
||||
};
|
||||
let unmapped_wire = serde_json::to_value(&unmapped).unwrap();
|
||||
assert_eq!(serde_json::from_value::<crate::import::ImportWarning>(unmapped_wire).unwrap(), unmapped);
|
||||
|
||||
// A second import still diagnoses the untouched foreign packet, while an XMP-free photo does not.
|
||||
drop(s);
|
||||
let clean = src.join("clean.png");
|
||||
write_png(&clean, 7);
|
||||
let mut s = Session::new().with_fs();
|
||||
let clean_report = s.execute("library.import", &json!({"paths": [clean.to_string_lossy()]})).unwrap();
|
||||
assert!(clean_report.get("warnings").is_none(), "{clean_report}");
|
||||
let repeat = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(repeat["warnings"].as_array().unwrap().len(), 2, "{repeat}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
@@ -566,6 +696,45 @@ fn external_edit_copy_is_stacked() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #920: with several photos selected, each gets its own `-Edit` copy stacked on its
|
||||
/// original, the copies end up selected and are all reported; `ids` picks photos explicitly.
|
||||
#[test]
|
||||
fn external_edit_copies_every_selected_photo() {
|
||||
let dir = temp_dir("extedit-many");
|
||||
let paths: Vec<String> = ["a", "b", "c"]
|
||||
.iter()
|
||||
.zip(9u8..)
|
||||
.map(|(n, seed)| {
|
||||
let p = dir.join(format!("{n}.png"));
|
||||
// different bytes: the import keeps one of identical files
|
||||
write_png(&p, seed);
|
||||
p.to_string_lossy().into_owned()
|
||||
})
|
||||
.collect();
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": paths})).unwrap();
|
||||
let originals: Vec<u64> = imported["imported"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
|
||||
assert_eq!(originals.len(), 3);
|
||||
s.execute("library.select", &json!({"ids": originals})).unwrap();
|
||||
let r = s.execute("photo.editExternal", &json!({})).unwrap();
|
||||
let copies = r["copies"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 3, "{r}");
|
||||
for (copy, orig) in copies.iter().zip(&originals) {
|
||||
assert_eq!(copy["original"].as_u64(), Some(*orig));
|
||||
assert!(copy["path"].as_str().unwrap().ends_with("-Edit.tif"), "{copy}");
|
||||
let new = lightcraft_catalog::PhotoId(copy["id"].as_u64().unwrap());
|
||||
let st = s.catalog.stack_of(new).expect("stacked");
|
||||
assert_eq!((st.top(), st.photos.contains(&lightcraft_catalog::PhotoId(*orig))), (new, true));
|
||||
assert!(s.selection.contains(new), "the copies are selected");
|
||||
}
|
||||
assert_eq!(r["path"], copies[0]["path"], "the single-copy fields name the first copy");
|
||||
// an edit since the first copy: identical bytes would be skipped as a duplicate import
|
||||
s.execute("develop.set", &json!({"ids": [originals[1]], "control": "light.exposure", "value": 0.5})).unwrap();
|
||||
let one = s.execute("photo.editExternal", &json!({"ids": [originals[1]]})).unwrap();
|
||||
assert_eq!(one["copies"].as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Edit in PhotoCraft (engine half): a flat 16-bit PSD `-Edit` copy, which PhotoCraft saves back
|
||||
/// to in place (it writes a TIFF only through Save As); its saved pixels come back on Reload.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Opt-in test of the embedded-preview read path on a **real file the user has** — typically one on a
|
||||
//! network share, which is where reading only the header instead of the whole raw matters:
|
||||
//!
|
||||
//! LC_PREVIEW_RAW="/run/user/1000/gvfs/smb-share:.../DSCF0001.RAF" \
|
||||
//! cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture
|
||||
//!
|
||||
//! It reports what the preview cost and fails when the read is not the small part of the file the
|
||||
//! header path promises. Skipped without `LC_PREVIEW_RAW` (there is no committed raw fixture: media
|
||||
//! is never committed, see AGENTS.md).
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"]
|
||||
fn a_preview_reads_only_the_header_and_the_preview() {
|
||||
let Ok(path) = std::env::var("LC_PREVIEW_RAW") else {
|
||||
eprintln!("skipped: set LC_PREVIEW_RAW=<path to a raw file>");
|
||||
return;
|
||||
};
|
||||
let file_len = std::fs::metadata(&path).expect("the file exists").len();
|
||||
assert!(file_len > 0, "empty file");
|
||||
|
||||
let started = Instant::now();
|
||||
let window =
|
||||
lightcraft_engine::files::preview_window(&path).expect("the file reads").expect("it carries an embedded preview the header can point at");
|
||||
let elapsed = started.elapsed();
|
||||
let (_, preview, read) = window;
|
||||
|
||||
let frac = read.fraction();
|
||||
println!(
|
||||
"preview of {path}\n read {} of {} bytes ({:.1} %) in {:.2} s{}",
|
||||
read.read,
|
||||
read.total,
|
||||
frac * 100.0,
|
||||
elapsed.as_secs_f64(),
|
||||
if read.header_path { " [header path]" } else { " [whole file]" }
|
||||
);
|
||||
|
||||
assert!(read.header_path, "the header path must answer for a raw whose pointer is in its header");
|
||||
assert_eq!(read.total, file_len, "the report covers the whole file");
|
||||
assert!(frac < 0.5, "a preview must not cost most of the file: read {} of {} bytes", read.read, read.total);
|
||||
assert!(preview.starts_with(&[0xff, 0xd8]), "the preview bytes are a JPEG: {:?}", preview.get(..4));
|
||||
}
|
||||
@@ -24,8 +24,12 @@ pub struct Headless {
|
||||
}
|
||||
|
||||
impl Drop for Headless {
|
||||
/// Snapshot a persistent library on exit (a no-op for in-memory sessions).
|
||||
/// Snapshot a persistent library on exit (a no-op for in-memory sessions), after any Apple
|
||||
/// Photos import still running (`export.addToPhotos {wait: false}`) has ended: the process
|
||||
/// must not end in the middle of one, abandoning osascript and the import's result.
|
||||
fn drop(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.session.wait_for_photos_imports();
|
||||
let _ = self.session.close_library();
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,8 @@ impl Headless {
|
||||
};
|
||||
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
|
||||
let write = &mut lightcraft_engine::export::write_file;
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = self.session.after_export(&opts)?;
|
||||
let items = prepare_batch(&mut self.session, &ids, &opts)?;
|
||||
let total = items.len();
|
||||
let mut hook = self.progress.take();
|
||||
@@ -109,6 +115,9 @@ impl Headless {
|
||||
}
|
||||
// Single-photo exports also report path/width/height at the top level (back-compat).
|
||||
let mut out = files.first().cloned().unwrap_or_else(|| json!({}));
|
||||
if let Some(photos) = after.run(&files, false) {
|
||||
out["applePhotos"] = photos;
|
||||
}
|
||||
out["files"] = json!(files);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -133,7 +142,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
@@ -185,7 +194,7 @@ pub fn expand_paths(paths: &[String]) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless),
|
||||
/// `.avif` — via the shared export encoder (embeds an sRGB profile).
|
||||
/// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile).
|
||||
pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, String> {
|
||||
use lightcraft_engine::export::{ExportFormat, ExportOptions};
|
||||
let format = ExportFormat::parse(ext).unwrap_or(ExportFormat::Png);
|
||||
|
||||
@@ -152,6 +152,99 @@ fn export_tool_forwards_its_advertised_options() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #236: `app.export {addToPhotos, photosAlbum}` through the headless backend (the MCP
|
||||
/// `export` tool, `lightcraft-cli run`): the files written go to Photos and the result says how
|
||||
/// it went; where Photos isn't (not macOS), it is refused before anything is written. A fake
|
||||
/// runner replaces the real one first: tests never start osascript or Photos.
|
||||
#[test]
|
||||
fn export_adds_to_apple_photos_where_there_is_photos() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut b = Headless::demo();
|
||||
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
|
||||
let c = calls.clone();
|
||||
b.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID-1/L0/001\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-export-photos-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let r = call_tool(&mut b, "export", &json!({"dir": base.to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "From \"MCP\""}));
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let out = r.structured.unwrap();
|
||||
assert_eq!(out["applePhotos"]["imported"], 1, "{out}");
|
||||
assert_eq!(out["applePhotos"]["album"], "From \"MCP\"");
|
||||
let written = out["files"][0]["path"].as_str().unwrap().to_string();
|
||||
let args = calls.lock().unwrap().clone();
|
||||
assert_eq!(args.len(), 1);
|
||||
assert_eq!(args[0].last(), Some(&written));
|
||||
} else {
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(format!("{:?}", r.content).contains("only available on macOS"), "{r:?}");
|
||||
assert!(!base.exists(), "nothing written");
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Review of #236: a one-shot process (`lightcraft-cli run`, an MCP server reaching EOF) must not
|
||||
/// end in the middle of an Apple Photos import, abandoning osascript and the result. The test runs
|
||||
/// itself as a child process (`LC_PHOTOS_CHILD`): the child adds a file with a fake runner that
|
||||
/// takes a while and then leaves a marker, prints the reply and ends as the CLI does (dropping
|
||||
/// its backend). The parent checks the import had finished: by default the call waited for
|
||||
/// Photos, and with `wait: false` the backend waited before the process ended. Never osascript:
|
||||
/// the child's session has no file-system hooks and only the fake runner.
|
||||
#[test]
|
||||
fn a_one_shot_process_never_ends_during_a_photos_import() {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
if let (Ok(dir), Ok(mode)) = (std::env::var("LC_PHOTOS_CHILD"), std::env::var("LC_PHOTOS_CHILD_MODE")) {
|
||||
let dir = std::path::PathBuf::from(dir);
|
||||
let file = dir.join("a.jpg");
|
||||
let marker = dir.join("imported");
|
||||
let mut h = Headless::new(lightcraft_engine::Session::with_demo());
|
||||
h.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
std::fs::write(&marker, args.last().cloned().unwrap_or_default()).unwrap();
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID-1\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let mut p = json!({"paths": [file.to_string_lossy()]});
|
||||
if mode == "nowait" {
|
||||
p["wait"] = json!(false);
|
||||
}
|
||||
let r = h.call("engine.execute", json!({"command": "export.addToPhotos", "params": p})).unwrap();
|
||||
println!("CHILD-REPLY {r}");
|
||||
drop(h); // what lightcraft-cli does before it exits
|
||||
return;
|
||||
}
|
||||
for mode in ["default", "nowait"] {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-photos-child-{mode}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.jpg"), b"jpeg").unwrap();
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "tests::a_one_shot_process_never_ends_during_a_photos_import", "--nocapture", "--test-threads=1"])
|
||||
.env("LC_PHOTOS_CHILD", &dir)
|
||||
.env("LC_PHOTOS_CHILD_MODE", mode)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(out.status.success(), "{mode}: {stdout}\n{}", String::from_utf8_lossy(&out.stderr));
|
||||
let reply: Value = stdout
|
||||
.lines()
|
||||
.find_map(|l| l.split_once("CHILD-REPLY ").map(|(_, j)| serde_json::from_str(j).unwrap()))
|
||||
.unwrap_or_else(|| panic!("{mode}: no reply in {stdout}"));
|
||||
let marker = std::fs::read_to_string(dir.join("imported")).unwrap_or_else(|_| panic!("{mode}: the process ended before the import did"));
|
||||
assert!(marker.ends_with("a.jpg"), "{marker}");
|
||||
if mode == "default" {
|
||||
assert_eq!((reply["running"].as_bool(), reply["imported"].as_u64()), (Some(false), Some(1)), "waited: {reply}");
|
||||
} else {
|
||||
assert_eq!(reply["running"], true, "{reply}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
|
||||
+12
-5
@@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
|
||||
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
|
||||
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]},
|
||||
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
|
||||
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng"], "description": "psd = a flat PSD (no layers); original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
|
||||
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
|
||||
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
|
||||
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"},
|
||||
@@ -248,7 +248,10 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"percent": {"type": "number", "description": "Output size as a percentage of full size"},
|
||||
"dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"},
|
||||
"ppi": {"type": "integer", "description": "Print resolution written into the file (default 240)"},
|
||||
"quality": {"type": "integer", "description": "JPEG/AVIF quality 1..100"},
|
||||
"quality": {"type": "integer", "description": "JPEG/AVIF/lossy JPEG XL quality 1..100"},
|
||||
"jxlLossless": {"type": "boolean", "description": "JPEG XL: mathematically lossless (quality unused); default false"},
|
||||
"jxlEffort": {"type": "string", "enum": ["fast", "normal"], "description": "JPEG XL encoder effort: normal (default) makes files about a quarter smaller than fast, in about twice the time"},
|
||||
"jxlProgressive": {"type": "boolean", "description": "JPEG XL: progressive file, a coarse image first (default false)"},
|
||||
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
|
||||
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
|
||||
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
|
||||
@@ -256,9 +259,11 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit and PNG as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"},
|
||||
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
|
||||
"addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"},
|
||||
"photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"},
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
|
||||
}),
|
||||
&[],
|
||||
@@ -745,6 +750,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
"dngCompression",
|
||||
"addToPhotos",
|
||||
"photosAlbum",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -468,6 +468,37 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor
|
||||
if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() }
|
||||
}
|
||||
|
||||
/// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures.
|
||||
pub const HDR_HISTOGRAM_EDGE: usize = 384;
|
||||
|
||||
/// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR
|
||||
/// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the
|
||||
/// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR
|
||||
/// white, whatever the headroom limit, so the scale stays put) on the right ([`Histogram::of_hdr`]). A preview of
|
||||
/// an HDR edit shows its SDR rendition; this says how far its highlights go. `None` for SDR edits,
|
||||
/// high-bit-depth, windowed and proofed requests (their histogram stays the image's).
|
||||
pub fn hdr_histogram(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Option<Histogram> {
|
||||
// a drag's drafts keep their own histogram: a second render per tick would slow them down
|
||||
if !s.hdr.enabled || req.depth != OutputDepth::U8 || req.window.is_some() || req.proof.is_some() || req.quality == Quality::Draft {
|
||||
return None;
|
||||
}
|
||||
let k = (HDR_HISTOGRAM_EDGE as f64 / req.max_w.max(req.max_h).max(1) as f64).min(1.0);
|
||||
let size = |v: usize| ((v as f64 * k).round() as usize).max(1);
|
||||
let hreq = RenderRequest {
|
||||
max_w: size(req.max_w),
|
||||
max_h: size(req.max_h),
|
||||
depth: OutputDepth::F32Hdr,
|
||||
overlay: Overlay::None,
|
||||
proof: None,
|
||||
window: None,
|
||||
display: None,
|
||||
..*req
|
||||
};
|
||||
let deep = render(src, info, s, &hreq).deep?;
|
||||
let DeepSamples::F32(v) = &deep.samples else { return None };
|
||||
Some(Histogram::of_hdr(v.as_chunks::<3>().0, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32))
|
||||
}
|
||||
|
||||
/// Render `src` with settings `s`.
|
||||
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
|
||||
render_impl(Src::Borrowed(src), info, s, req, None)
|
||||
|
||||
@@ -137,3 +137,36 @@ fn visualize_hdr_paints_only_what_rises_above_white() {
|
||||
let plain = render(&src, &raw(), &DevelopSettings::default(), &RenderRequest::fit(240, 240));
|
||||
assert_eq!(off.image.data, plain.image.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_histogram_shows_the_range_above_sdr_white_on_a_fixed_scale() {
|
||||
let src = sunset();
|
||||
let req = RenderRequest::fit(240, 240);
|
||||
// SDR edits, and requests the image's own histogram describes, have none
|
||||
assert!(crate::hdr_histogram(&src, &raw(), &DevelopSettings::default(), &req).is_none());
|
||||
let mut s = hdr_on(3.0);
|
||||
s.light.exposure = 1.0;
|
||||
for other in [
|
||||
RenderRequest { depth: OutputDepth::U16, ..req },
|
||||
RenderRequest { window: Some(crate::PixelWindow { x: 0, y: 0, w: 8, h: 8 }), ..req },
|
||||
RenderRequest { proof: Some(crate::Proof::default()), ..req },
|
||||
] {
|
||||
assert!(crate::hdr_histogram(&src, &raw(), &s, &other).is_none());
|
||||
}
|
||||
let h = crate::hdr_histogram(&src, &raw(), &s, &req).expect("an HDR edit's histogram");
|
||||
let bins = h.hdr.expect("HDR bins");
|
||||
assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "the whole HDR range");
|
||||
assert!(h.above_sdr() > 0.0, "highlights above SDR white: {}", h.above_sdr());
|
||||
assert!(h.total > 0);
|
||||
// the scale doesn't follow the headroom limit (a damaged one included); the content does
|
||||
let highest = |h: &lightcraft_raster::Histogram| h.luma.iter().rposition(|&n| n > 0).unwrap_or(0);
|
||||
let top3 = highest(&h);
|
||||
s.hdr.max_ev = 1.5;
|
||||
let h15 = crate::hdr_histogram(&src, &raw(), &s, &req).unwrap();
|
||||
assert_eq!(h15.hdr.unwrap().stops, bins.stops);
|
||||
assert!(highest(&h15) < top3, "a lower limit pulls the highlights down: {} vs {top3}", highest(&h15));
|
||||
s.hdr.max_ev = f64::NAN;
|
||||
assert_eq!(crate::hdr_histogram(&src, &raw(), &s, &req).unwrap().hdr.unwrap().stops, bins.stops);
|
||||
// the SDR rendition's own histogram has nothing above white
|
||||
assert!(render(&src, &raw(), &hdr_on(3.0), &req).histogram.hdr.is_none());
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Histograms of display-encoded images (what the Histogram panel shows).
|
||||
//!
|
||||
//! An HDR histogram ([`Histogram::of_hdr`]) keeps the same 256 bins: the first
|
||||
//! [`Histogram::hdr_from`] cover SDR black to SDR white (sRGB-encoded, as an SDR histogram), the
|
||||
//! rest cover the HDR range above SDR white in equal stops.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -11,13 +15,122 @@ pub struct Histogram {
|
||||
pub b: Vec<u32>,
|
||||
pub luma: Vec<u32>,
|
||||
pub total: u32,
|
||||
/// HDR histograms: the first bin above SDR white, and the stops above SDR white the last bin
|
||||
/// reaches. `None` for SDR histograms (every bin is SDR).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hdr: Option<HdrBins>,
|
||||
}
|
||||
|
||||
/// The HDR part of a [`Histogram`]: bins `from..256` cover `0..stops` stops above SDR white.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HdrBins {
|
||||
pub from: usize,
|
||||
pub stops: f32,
|
||||
}
|
||||
|
||||
impl HdrBins {
|
||||
/// The bin of a linear value (1 = SDR white; `NaN` and negatives count as black).
|
||||
pub fn bin(self, v: f32) -> usize {
|
||||
let last = Histogram::BINS - 1;
|
||||
// a split outside 2..=last would leave one side without bins (or underflow)
|
||||
let from = self.from.clamp(2, last);
|
||||
if v.is_nan() || v <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
if v <= 1.0 {
|
||||
let e = lightcraft_color::transfer::linear_to_srgb(v);
|
||||
return ((e * (from - 1) as f32 + 0.5) as usize).min(from - 1);
|
||||
}
|
||||
let stops = if self.stops.is_finite() { self.stops.max(1e-3) } else { 1e-3 };
|
||||
let t = (v.log2() / stops).clamp(0.0, 1.0);
|
||||
(from + (t * (last - from) as f32 + 0.5) as usize).min(last)
|
||||
}
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
pub const BINS: usize = 256;
|
||||
/// HDR histograms: the first bin above SDR white (SDR takes the left half).
|
||||
pub const HDR_FROM: usize = Self::BINS / 2;
|
||||
|
||||
fn empty(hdr: Option<HdrBins>) -> Histogram {
|
||||
Histogram { r: vec![0; 256], g: vec![0; 256], b: vec![0; 256], luma: vec![0; 256], total: 0, hdr }
|
||||
}
|
||||
|
||||
/// Histogram of linear RGB samples (1 = SDR white) reaching `stops` stops above SDR white.
|
||||
/// `samples` are visited at most ~1 MP of them (evenly strided).
|
||||
pub fn of_hdr(samples: &[[f32; 3]], stops: f32) -> Histogram {
|
||||
let bins = HdrBins { from: Self::HDR_FROM, stops };
|
||||
let mut h = Self::empty(Some(bins));
|
||||
let step = (samples.len() / 1_000_000).max(1);
|
||||
for c in samples.iter().step_by(step) {
|
||||
h.r[bins.bin(c[0])] += 1;
|
||||
h.g[bins.bin(c[1])] += 1;
|
||||
h.b[bins.bin(c[2])] += 1;
|
||||
h.luma[bins.bin(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])] += 1;
|
||||
h.total += 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// HDR histogram of per-pixel bins already computed with `bins` (`[r, g, b, luma]` each),
|
||||
/// visiting at most ~1 MP of them.
|
||||
pub fn of_bins(px: impl ExactSizeIterator<Item = [u8; 4]>, bins: HdrBins) -> Histogram {
|
||||
let mut h = Self::empty(Some(bins));
|
||||
let step = (px.len() / 1_000_000).max(1);
|
||||
for [r, g, b, l] in px.step_by(step) {
|
||||
h.r[r as usize] += 1;
|
||||
h.g[g as usize] += 1;
|
||||
h.b[b as usize] += 1;
|
||||
h.luma[l as usize] += 1;
|
||||
h.total += 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The histogram as flat words (to cross a worker boundary): r, g, b, luma (256 each), the
|
||||
/// total, then the first HDR bin (0: SDR) and the HDR stops as `f32` bits.
|
||||
pub fn to_words(&self) -> Vec<u32> {
|
||||
let mut w = Vec::with_capacity(4 * Self::BINS + 3);
|
||||
for ch in [&self.r, &self.g, &self.b, &self.luma] {
|
||||
w.extend(ch.iter().copied().chain(std::iter::repeat(0)).take(Self::BINS));
|
||||
}
|
||||
w.push(self.total);
|
||||
let (from, stops) = self.hdr.map_or((0, 0.0), |b| (b.from as u32, b.stops));
|
||||
w.extend([from, stops.to_bits()]);
|
||||
w
|
||||
}
|
||||
|
||||
/// [`Histogram::to_words`] back; also takes the older layout without the HDR words. `None`
|
||||
/// for anything malformed.
|
||||
pub fn from_words(w: &[u32]) -> Option<Histogram> {
|
||||
let n = Self::BINS;
|
||||
if w.len() != 4 * n + 1 && w.len() != 4 * n + 3 {
|
||||
return None;
|
||||
}
|
||||
let ch = |k: usize| w.get(k * n..(k + 1) * n).map(<[u32]>::to_vec);
|
||||
let hdr = match (w.get(4 * n + 1), w.get(4 * n + 2)) {
|
||||
(Some(&from), Some(&bits)) if from > 1 && (from as usize) < n => {
|
||||
let stops = f32::from_bits(bits);
|
||||
Some(HdrBins { from: from as usize, stops: if stops.is_finite() && stops > 0.0 { stops } else { 1.0 } })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(Histogram { r: ch(0)?, g: ch(1)?, b: ch(2)?, luma: ch(3)?, total: *w.get(4 * n)?, hdr })
|
||||
}
|
||||
|
||||
/// Fraction of pixels above SDR white in any channel (0 for SDR histograms).
|
||||
pub fn above_sdr(&self) -> f32 {
|
||||
let Some(b) = self.hdr else { return 0.0 };
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let over = |v: &[u32]| v.iter().skip(b.from).sum::<u32>();
|
||||
over(&self.r).max(over(&self.g)).max(over(&self.b)) as f32 / self.total as f32
|
||||
}
|
||||
|
||||
pub fn of_srgb8(img: &Rgba8) -> Histogram {
|
||||
let mut h = Histogram { r: vec![0; 256], g: vec![0; 256], b: vec![0; 256], luma: vec![0; 256], total: 0 };
|
||||
let mut h = Self::empty(None);
|
||||
// Sample at most ~1 MP for speed.
|
||||
let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1);
|
||||
for y in (0..img.height).step_by(step) {
|
||||
@@ -83,5 +196,53 @@ mod tests {
|
||||
assert!((h.mean_luma() - 0.5).abs() < 1e-3);
|
||||
assert_eq!(h.percentile(0.25), 0.0);
|
||||
assert_eq!(h.percentile(0.75), 1.0);
|
||||
assert_eq!(h.above_sdr(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_bins_split_sdr_and_stops() {
|
||||
let px = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [4.0, 4.0, 4.0], [16.0, 16.0, 16.0], [f32::NAN, -1.0, f32::INFINITY]];
|
||||
let h = Histogram::of_hdr(&px, 4.0);
|
||||
let b = h.hdr.unwrap();
|
||||
assert_eq!(h.total, 5);
|
||||
assert_eq!(b.bin(1.0), b.from - 1, "SDR white is the last SDR bin");
|
||||
assert_eq!(b.bin(16.0), 255);
|
||||
assert_eq!(b.bin(1e9), 255);
|
||||
let two = b.bin(4.0);
|
||||
assert!(two > b.from && two < 255, "{two}");
|
||||
assert_eq!(h.r[0], 2, "black and NaN");
|
||||
assert_eq!(h.b[255], 2, "16 and +inf");
|
||||
assert!((h.above_sdr() - 0.6).abs() < 1e-6, "{}", h.above_sdr());
|
||||
assert_eq!(Histogram::of_hdr(&[], 4.0).above_sdr(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_round_trip_and_reject_garbage() {
|
||||
let img = Rgba8::from_fn(8, 8, |x, y| [(x * 30) as u8, (y * 30) as u8, 9, 255]);
|
||||
let sdr = Histogram::of_srgb8(&img);
|
||||
assert_eq!(Histogram::from_words(&sdr.to_words()), Some(sdr.clone()));
|
||||
let hdr = Histogram::of_hdr(&[[0.5, 2.0, 9.0]], 4.0);
|
||||
assert_eq!(Histogram::from_words(&hdr.to_words()), Some(hdr));
|
||||
// the older layout (no HDR words) is an SDR histogram
|
||||
assert_eq!(Histogram::from_words(&sdr.to_words()[..1025]), Some(sdr.clone()));
|
||||
assert_eq!(Histogram::from_words(&[1, 2, 3]), None);
|
||||
let mut bad = sdr.to_words();
|
||||
bad[1025] = 9999; // first HDR bin out of range: SDR
|
||||
bad[1026] = f32::NAN.to_bits();
|
||||
assert_eq!(Histogram::from_words(&bad).unwrap().hdr, None);
|
||||
bad[1025] = 100; // NaN stops fall back to something drawable
|
||||
assert_eq!(Histogram::from_words(&bad).unwrap().hdr.unwrap().stops, 1.0);
|
||||
}
|
||||
/// A hand-built split outside the bins (any `from`, any `stops`) never panics and stays in range.
|
||||
#[test]
|
||||
fn hdr_bins_with_any_split_stay_in_range() {
|
||||
for from in [0, 1, 2, 128, 255, 256, usize::MAX] {
|
||||
for stops in [0.0, 3.0, f32::NAN, f32::INFINITY] {
|
||||
let b = HdrBins { from, stops };
|
||||
for v in [f32::NAN, -1.0, 0.0, 0.5, 1.0, 2.0, 1e9] {
|
||||
assert!(b.bin(v) < Histogram::BINS, "{from} {stops} {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ pub mod blur;
|
||||
pub mod histogram;
|
||||
pub mod resample;
|
||||
|
||||
pub use histogram::Histogram;
|
||||
pub use histogram::{HdrBins, Histogram};
|
||||
|
||||
/// Run `f` over row chunks, in parallel when the `parallel` feature is on.
|
||||
pub fn par_rows<T: Send>(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||||
|
||||
@@ -54,7 +54,10 @@ pub use lightcraft_geom::Orientation;
|
||||
pub use lightcraft_meta::Metadata;
|
||||
pub use lightcraft_raster::Rgb32f;
|
||||
pub use opcodes::{Opcode, OpcodeLists};
|
||||
pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_preview_dynamic_range_optimized};
|
||||
pub use preview::{
|
||||
PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space,
|
||||
embedded_preview_dynamic_range_optimized, embedded_preview_from_range,
|
||||
};
|
||||
pub use semantic::{SemanticMask, semantic_masks};
|
||||
|
||||
use lightcraft_color::Xy;
|
||||
|
||||
@@ -186,6 +186,63 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
/// walking the marker segments and the entropy-coded data. `None` when it is not a complete, displayable
|
||||
/// (baseline or progressive) JPEG, so a stray `D8 FF` in other data is rejected. `*reach` is left at
|
||||
/// the furthest byte looked at (what the walk cost; see [`scan_for_jpeg`]).
|
||||
/// Where a preview lives in a file, so a caller with a seekable file can read just that part of it.
|
||||
///
|
||||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: DSCF9492.RAF at
|
||||
/// 5.6 MB), so a grid thumbnail or a loupe preview has no reason to pull the remaining 50 MB —
|
||||
/// which on a network share is the difference between a moment and half a minute. A caller that has
|
||||
/// only bytes (`wasm`, tests) keeps using [`embedded_preview`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PreviewRange {
|
||||
/// Byte offset of the preview in the file.
|
||||
pub offset: u64,
|
||||
/// Bytes to read at [`Self::offset`]: the container's declared preview extent, which may carry
|
||||
/// padding past the preview's end-of-image marker.
|
||||
pub window_len: u64,
|
||||
}
|
||||
|
||||
/// The preview's range in the file, when the file's header alone locates it.
|
||||
///
|
||||
/// `bytes` is the file's first [`PREVIEW_PREFIX`] bytes (more is fine). `None` means "read the file
|
||||
/// whole and call [`embedded_preview`]": the formats not covered here (a TIFF-based raw's pointer
|
||||
/// tag can sit in an IFD the prefix cuts short; CR3 scans box by box) and files whose pointer is
|
||||
/// damaged.
|
||||
///
|
||||
/// The caller reads `[range.offset, range.offset + range.window_len)` and passes those bytes to
|
||||
/// [`embedded_preview_from_range`], which returns exactly what [`embedded_preview`] returns for the
|
||||
/// whole file.
|
||||
///
|
||||
/// Pass a prefix that actually covers the pointer's range (read [`Self::offset`] +
|
||||
/// [`Self::window_len`] bytes, or start with [`PREVIEW_PREFIX`] and grow when it is not enough):
|
||||
/// [`crate::vendor::raf::header`] clamps a slice to the buffer it was given, so a prefix that stops
|
||||
/// inside the declared extent yields a shorter `window_len` than the file really holds.
|
||||
pub fn bounded_preview(bytes: &[u8]) -> Option<PreviewRange> {
|
||||
if !bytes.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
return None;
|
||||
}
|
||||
let jpeg = crate::vendor::raf::header(bytes).ok()?.jpeg?;
|
||||
let offset = (jpeg.as_ptr() as usize).checked_sub(bytes.as_ptr() as usize)?;
|
||||
if offset.checked_add(jpeg.len())? > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
Some(PreviewRange { offset: offset as u64, window_len: jpeg.len() as u64 })
|
||||
}
|
||||
|
||||
/// The preview inside `window`, which holds the file's bytes at `[range.offset, range.offset +
|
||||
/// range.window_len)`: the counterpart of [`bounded_preview`]. Padding past the end-of-image marker
|
||||
/// is dropped, exactly as [`embedded_preview`] drops it.
|
||||
pub fn embedded_preview_from_range(window: &[u8], range: PreviewRange) -> Option<Vec<u8>> {
|
||||
let preview = window.get(..usize::try_from(range.window_len).ok()?)?;
|
||||
if !is_dct_jpeg(preview) {
|
||||
return None;
|
||||
}
|
||||
Some(trim_eoi(preview).to_vec())
|
||||
}
|
||||
|
||||
/// How many bytes of a raw file are enough for [`bounded_preview`]: every covered format keeps its
|
||||
/// preview pointer in a fixed header, well inside this.
|
||||
pub const PREVIEW_PREFIX: usize = 4096;
|
||||
|
||||
fn jpeg_extent(b: &[u8], soi: usize, reach: &mut usize) -> Option<(usize, u8)> {
|
||||
let mut i = soi + 2;
|
||||
let r = walk_jpeg(b, &mut i);
|
||||
@@ -341,6 +398,112 @@ mod tests {
|
||||
j
|
||||
}
|
||||
|
||||
/// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the
|
||||
/// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than
|
||||
/// the JPEG holds the way a padded container does.
|
||||
fn raf_fixture(jpeg: &[u8], gap: usize, preamble: usize, declared_len: Option<usize>) -> Vec<u8> {
|
||||
let head = 160 + preamble;
|
||||
let jpeg_off = head;
|
||||
let mut f = vec![0u8; head];
|
||||
f[..16].copy_from_slice(b"FUJIFILMCCD-RAW ");
|
||||
f[84..88].copy_from_slice(&(jpeg_off as u32).to_be_bytes());
|
||||
f[88..92].copy_from_slice(&(declared_len.unwrap_or(jpeg.len()) as u32).to_be_bytes());
|
||||
// records directory and raw block are absent (offset 0), which `header` reads as "none"
|
||||
f.extend_from_slice(jpeg);
|
||||
f.extend(std::iter::repeat_n(0x33u8, gap));
|
||||
f
|
||||
}
|
||||
|
||||
/// A RAF's preview pointer sits at a fixed offset, so a header-sized prefix locates it and the
|
||||
/// caller reads just that range instead of the whole file — the whole point on a network share.
|
||||
#[test]
|
||||
fn bounded_preview_locates_a_rafs_jpeg_from_the_header_alone() {
|
||||
let jpeg = fake_jpeg(5000);
|
||||
let file = raf_fixture(&jpeg, 40_000, 0, None);
|
||||
// A prefix that covers the pointer's range: header + JPEG, and nothing of the tail.
|
||||
let prefix_len = 8192.min(file.len());
|
||||
let prefix = &file[..prefix_len];
|
||||
|
||||
let range = bounded_preview(prefix).expect("a RAF's preview is found from its header");
|
||||
assert_eq!(range.offset, 160);
|
||||
assert_eq!(range.window_len, jpeg.len() as u64);
|
||||
assert!(range.offset + range.window_len <= prefix_len as u64, "the prefix covers the whole range: {range:?} of {prefix_len}");
|
||||
assert!((range.offset + range.window_len) < file.len() as u64, "the range stops short of the tail: {range:?} of {}", file.len());
|
||||
|
||||
// What the caller reads, and what it holds afterwards: identical to the whole-file answer.
|
||||
let window = prefix.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the range lies inside the prefix");
|
||||
let bounded = embedded_preview_from_range(window, range).expect("the range holds the preview");
|
||||
assert_eq!(Some(bounded.clone()), embedded_preview(&file));
|
||||
assert!(bounded.ends_with(&[0xff, 0xd9]), "the preview ends at its EOI marker");
|
||||
|
||||
// A prefix that stops inside the declared extent is clamped by the container reader. The
|
||||
// caller then reads a short window; what comes back is the whole JPEG it holds (the EOI
|
||||
// marker is inside), never bytes past the window.
|
||||
let short = &file[..2000];
|
||||
let clipped = bounded_preview(short).expect("a pointer before the cut is still found");
|
||||
assert!(clipped.window_len < jpeg.len() as u64, "clamped: {clipped:?}");
|
||||
let window = short.get(clipped.offset as usize..).expect("inside");
|
||||
let preview = embedded_preview_from_range(window, clipped).expect("the bytes are a JPEG start");
|
||||
// No end-of-image marker inside the window: the bytes come back as they are, exactly as the
|
||||
// whole-file path returns an unclosed JPEG. The decoder refuses it later; nothing is invented.
|
||||
assert_eq!(preview, window, "an unclosed JPEG is handed on unchanged, never padded");
|
||||
assert!(preview.len() <= window.len(), "never past the window");
|
||||
}
|
||||
|
||||
/// The preview pointer may sit past the prefix (a long maker-note record before it), and a
|
||||
/// container may declare padding after the JPEG: both stay correct, and the padding is dropped.
|
||||
#[test]
|
||||
fn bounded_preview_handles_a_late_pointer_and_padding() {
|
||||
let jpeg = fake_jpeg(300);
|
||||
let late = raf_fixture(&jpeg, 0, 9000, None);
|
||||
let range = bounded_preview(&late).expect("the whole file is always a valid prefix");
|
||||
assert_eq!(range.offset, (160 + 9000) as u64);
|
||||
let padded = raf_fixture(&jpeg, 500, 0, Some(jpeg.len() + 500));
|
||||
let range = bounded_preview(&padded).expect("a padded extent is still located");
|
||||
assert_eq!(range.window_len, (jpeg.len() + 500) as u64);
|
||||
let window = padded.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the window is inside the file");
|
||||
let preview = embedded_preview_from_range(window, range).expect("the padding is dropped");
|
||||
assert_eq!(preview, embedded_preview(&padded).expect("the whole-file answer agrees"));
|
||||
assert!(preview.ends_with(&[0xff, 0xd9]));
|
||||
}
|
||||
|
||||
/// Without a usable header or pointer the caller must read the file whole: the answer is `None`,
|
||||
/// never a wrong range.
|
||||
#[test]
|
||||
fn bounded_preview_declines_what_the_header_cannot_answer() {
|
||||
let jpeg = fake_jpeg(64);
|
||||
let file = raf_fixture(&jpeg, 0, 0, None);
|
||||
assert_eq!(bounded_preview(&jpeg), None, "not a raw at all");
|
||||
assert_eq!(bounded_preview(b""), None);
|
||||
if let Some(short) = file.get(..64) {
|
||||
assert_eq!(bounded_preview(short), None, "a header cut short has not shown the pointer yet");
|
||||
}
|
||||
// a pointer that claims more than the file holds: `header` clamps the slice to the buffer,
|
||||
// so the range never runs past the end (no out-of-bounds read can follow from it)
|
||||
let mut lying = raf_fixture(&jpeg, 4096, 0, None);
|
||||
lying[88..92].copy_from_slice(&(u32::MAX / 2).to_be_bytes());
|
||||
let clamped = bounded_preview(&lying).expect("a pointer inside the file is still usable");
|
||||
assert!(clamped.offset + clamped.window_len <= lying.len() as u64, "clamped to the file: {clamped:?}");
|
||||
// a range the window cannot satisfy yields nothing
|
||||
assert_eq!(embedded_preview_from_range(&jpeg, PreviewRange { offset: 0, window_len: u64::MAX }), None);
|
||||
// a TIFF-based raw: its pointer tag lives in an IFD this prefix may cut short
|
||||
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
|
||||
tiff.extend_from_slice(&[0u8; 64]);
|
||||
assert_eq!(bounded_preview(&tiff), None);
|
||||
}
|
||||
|
||||
/// A range that does not hold a JPEG yields nothing, rather than a truncated image handed on.
|
||||
#[test]
|
||||
fn a_range_that_does_not_hold_a_jpeg_yields_nothing() {
|
||||
let jpeg = fake_jpeg(200);
|
||||
let range = PreviewRange { offset: 0, window_len: jpeg.len() as u64 };
|
||||
assert_eq!(embedded_preview_from_range(&jpeg, range).as_deref(), Some(&jpeg[..]));
|
||||
assert_eq!(embedded_preview_from_range(&jpeg[..10], range), None, "window shorter than the range claims");
|
||||
let not_jpeg = vec![0u8; jpeg.len()];
|
||||
assert_eq!(embedded_preview_from_range(¬_jpeg, range), None, "not a JPEG");
|
||||
assert_eq!(embedded_preview_from_range(&[], PreviewRange { offset: 0, window_len: 0 }), None, "empty window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_largest_dct_jpeg() {
|
||||
let small = fake_jpeg(10);
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
"Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt",
|
||||
"Import failed: {e}": "Import fehlgeschlagen: {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Guided-Upright-Transformation wird nicht unterstützt.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Upright-Transformation wird nicht unterstützt.",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "XMP-Anpassungen wurden für {path} nicht importiert: {fields}.",
|
||||
"Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}",
|
||||
"Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Importierte Vorgaben: {n}{0:.0}, nicht lesbare Dateien: {f}{1:.0}",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)",
|
||||
"New Folder in “{name}”": "Neuer Ordner in „{name}“",
|
||||
"Moved {} photo(s)": "{} Foto(s) verschoben"
|
||||
"Moved {} photo(s)": "{} Foto(s) verschoben",
|
||||
"not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt",
|
||||
"{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt"
|
||||
}
|
||||
@@ -2104,5 +2104,10 @@
|
||||
"Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben",
|
||||
"Appearance Settings…": "Erscheinungsbild-Einstellungen…",
|
||||
"Add to favorites": "Zu Favoriten hinzufügen",
|
||||
"Remove from favorites": "Aus Favoriten entfernen"
|
||||
"Remove from favorites": "Aus Favoriten entfernen",
|
||||
"Add to Apple Photos": "Zu Apple Fotos hinzufügen",
|
||||
"Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…",
|
||||
"not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.",
|
||||
"Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt"
|
||||
}
|
||||
@@ -60,6 +60,13 @@
|
||||
"Healed {n} dust spot{}": "{n} mancha{0} de polvo corregida{0}",
|
||||
"Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}",
|
||||
"Import failed: {e}": "Error de importación: {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Upright está desactivado.",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Upright guiado no es compatible.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Upright no es compatible.",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "Los ajustes XMP no se importaron para {path}: {fields}.",
|
||||
"Imported {n} point curve preset{}": "{n} ajuste{0} preestablecido{0} de curva de puntos importado{0}",
|
||||
"Imported {n} preset{}": "{n} ajuste{0} preestablecido{0} importado{0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n} ajuste{0} preestablecido{0} importado{0}, {f} archivo{1} ilegible{1}",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "Combinación {what} cancelada",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)",
|
||||
"New Folder in “{name}”": "Nueva carpeta en «{name}»",
|
||||
"Moved {} photo(s)": "{} foto(s) movida(s)"
|
||||
"Moved {} photo(s)": "{} foto(s) movida(s)",
|
||||
"not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple",
|
||||
"{n} added to Apple Photos": "{n} añadidas a Fotos de Apple",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original"
|
||||
}
|
||||
@@ -1946,5 +1946,10 @@
|
||||
"Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe",
|
||||
"Appearance Settings…": "Ajustes de apariencia…",
|
||||
"Add to favorites": "Añadir a favoritos",
|
||||
"Remove from favorites": "Quitar de favoritos"
|
||||
"Remove from favorites": "Quitar de favoritos",
|
||||
"Add to Apple Photos": "Añadir a Fotos de Apple",
|
||||
"Adding to Apple Photos…": "Añadiendo a Fotos de Apple…",
|
||||
"not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.",
|
||||
"Adding to Apple Photos": "Añadiendo a Fotos de Apple"
|
||||
}
|
||||
@@ -60,10 +60,17 @@
|
||||
"Healed {n} dust spot{}": "{n} tache{} de poussière corrigée(s)",
|
||||
"Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)",
|
||||
"Import failed: {e}": "Échec de l’importation : {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "{n} préréglage{} de courbe de points importé(s)",
|
||||
"Imported {n} preset{}": "{n} préréglage{} importé(s)",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Le profil XMP « {profile} » n’a pas été appliqué à {path} ; les couleurs peuvent différer de Lightroom.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La géométrie Lightroom de {path} n’a pas été conservée ; {mode} sera recalculé.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée est ignorée, car Upright est désactivé.",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée de Guided Upright n’est pas prise en charge.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation Upright enregistrée n’est pas prise en charge.",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "Les réglages XMP de {path} n’ont pas été importés : {fields}.",
|
||||
"Imported {} photo{}": "{} photo{} importée(s)",
|
||||
"Insert at the cursor (also written {})": "Insérer à la position du curseur (également écrit {})",
|
||||
"Label Set: {name}": "Ensemble d’étiquettes : {name}",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "Fusion {what} annulée",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)",
|
||||
"New Folder in “{name}”": "Nouveau dossier dans « {name} »",
|
||||
"Moved {} photo(s)": "{} photo(s) déplacée(s)"
|
||||
"Moved {} photo(s)": "{} photo(s) déplacée(s)",
|
||||
"not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »",
|
||||
"{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original"
|
||||
}
|
||||
@@ -1946,5 +1946,10 @@
|
||||
"Charcoal": "Anthracite",
|
||||
"Midnight": "Minuit",
|
||||
"Silver": "Argent",
|
||||
"Paper": "Papier"
|
||||
"Paper": "Papier",
|
||||
"Add to Apple Photos": "Ajouter à Apple Photos",
|
||||
"Adding to Apple Photos…": "Ajout à Apple Photos…",
|
||||
"not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.",
|
||||
"Adding to Apple Photos": "Ajout à Apple Photos"
|
||||
}
|
||||
@@ -155,6 +155,13 @@
|
||||
"Folder “{}” (and its subfolders)": "フォルダ「{}」(サブフォルダを含む)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "見つからない写真を{n}枚見つけました{:.0}。{left}枚はまだ見つかりません{unsure}",
|
||||
"Import failed: {e}": "読み込みに失敗しました:{e}",
|
||||
"Import warnings ({n}):\n{warnings}": "読み込みの警告({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "{path} に XMP プロファイル \"{profile}\" を適用できませんでした。色は Lightroom と異なる場合があります。",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "{path} の Lightroom ジオメトリは保持されませんでした。{mode} を再計算します。",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "{path} の Lightroom ジオメトリは保持されませんでした。Upright がオフのため保存された変換は無視されます。",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "{path} の Lightroom ジオメトリは保持されませんでした。ガイド付き Upright の保存された変換はサポートされていません。",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "{path} の Lightroom ジオメトリは保持されませんでした。保存された Upright の変換はサポートされていません。",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "{path} の XMP 調整は読み込まれませんでした:{fields}。",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles}個のプロファイルを読み込みました{:.0}(プロファイルブラウザー → 各グループ)",
|
||||
"Insert at the cursor (also written {})": "カーソル位置に挿入({}とも表記可)",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。フォント:{}(OFL)。アイコン:オリジナル",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "{what}合成をキャンセルしました",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)",
|
||||
"New Folder in “{name}”": "「{name}」に新規フォルダ",
|
||||
"Moved {} photo(s)": "{} 枚の写真を移動しました"
|
||||
"Moved {} photo(s)": "{} 枚の写真を移動しました",
|
||||
"not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました",
|
||||
"{n} added to Apple Photos": "{n}件をAppleの写真に追加しました",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています"
|
||||
}
|
||||
@@ -1948,5 +1948,10 @@
|
||||
"Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません",
|
||||
"Appearance Settings…": "外観設定…",
|
||||
"Add to favorites": "お気に入りに追加",
|
||||
"Remove from favorites": "お気に入りから削除"
|
||||
"Remove from favorites": "お気に入りから削除",
|
||||
"Add to Apple Photos": "Appleの写真に追加",
|
||||
"Adding to Apple Photos…": "Appleの写真に追加中…",
|
||||
"not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。",
|
||||
"Adding to Apple Photos": "Appleの写真に追加中"
|
||||
}
|
||||
@@ -155,6 +155,13 @@
|
||||
"Folder “{}” (and its subfolders)": "Pasta “{}” (e suas subpastas)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "{n} foto{0} ausente{0} encontrada{0}; {left} ainda ausente(s){unsure}",
|
||||
"Import failed: {e}": "Falha na importação: {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "Avisos de importação ({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "O perfil XMP \"{profile}\" não foi aplicado a {path}; as cores podem diferir do Lightroom.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "A geometria do Lightroom não foi preservada para {path}; {mode} será recalculado.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "A geometria do Lightroom não foi preservada para {path}; a transformação salva é ignorada porque o Upright está desativado.",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "A geometria do Lightroom não foi preservada para {path}; a transformação salva do Upright guiado não é compatível.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "A geometria do Lightroom não foi preservada para {path}; a transformação salva do Upright não é compatível.",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "Os ajustes XMP não foram importados para {path}: {fields}.",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} perfil(is) importado(s){:.0} (Navegador de perfis → seus grupos)",
|
||||
"Insert at the cursor (also written {})": "Inserir no cursor (também escrito {})",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fontes: {} (OFL). Ícones: originais.",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "{what} mesclagem cancelada",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)",
|
||||
"New Folder in “{name}”": "Nova pasta em “{name}”",
|
||||
"Moved {} photo(s)": "{} foto(s) movida(s)"
|
||||
"Moved {} photo(s)": "{} foto(s) movida(s)",
|
||||
"not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple",
|
||||
"{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original"
|
||||
}
|
||||
@@ -1946,5 +1946,10 @@
|
||||
"Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve",
|
||||
"Appearance Settings…": "Configurações de aparência…",
|
||||
"Add to favorites": "Adicionar aos favoritos",
|
||||
"Remove from favorites": "Remover dos favoritos"
|
||||
"Remove from favorites": "Remover dos favoritos",
|
||||
"Add to Apple Photos": "Adicionar ao Fotos da Apple",
|
||||
"Adding to Apple Photos…": "Adicionando ao Fotos da Apple…",
|
||||
"not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.",
|
||||
"Adding to Apple Photos": "Adicionando ao Fotos da Apple"
|
||||
}
|
||||
@@ -63,6 +63,13 @@
|
||||
"Healed {n} dust spot{}": "Убрано пылинок: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}",
|
||||
"Import failed: {e}": "Ошибка импорта: {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрия Lightroom для {path} не сохранена; режим {mode} будет пересчитан.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование игнорируется, поскольку Upright выключен.",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование Guided Upright не поддерживается.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование Upright не поддерживается.",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "Коррекции XMP для {path} не импортированы: {fields}.",
|
||||
"Imported {n} point curve preset{}": "Импортировано пресетов точечной кривой: {n}{:.0}",
|
||||
"Imported {n} preset{}": "Импортировано пресетов: {n}{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Импортировано пресетов: {n}{:.0}, не читается файлов: {f}{:.0}",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "{what}: слияние отменено",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)",
|
||||
"New Folder in “{name}”": "Новая папка в «{name}»",
|
||||
"Moved {} photo(s)": "Перемещено фото: {}"
|
||||
"Moved {} photo(s)": "Перемещено фото: {}",
|
||||
"not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}",
|
||||
"{n} added to Apple Photos": "Добавлено в Apple Фото: {n}",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом"
|
||||
}
|
||||
+680
-199
File diff suppressed because it is too large.
Load diff
@@ -60,10 +60,17 @@
|
||||
"Healed {n} dust spot{}": "Усунено пилових плям: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Імпорт скасовано · додано фото: {}{:.0}",
|
||||
"Import failed: {e}": "Помилка імпорту: {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "Попередження імпорту ({n}):\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "Імпортовано наборів налаштувань точкової кривої: {n}{:.0}",
|
||||
"Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профіль XMP «{profile}» не застосовано до {path}; кольори можуть відрізнятися від Lightroom.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрію Lightroom для {path} не збережено; режим {mode} буде перераховано.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрію Lightroom для {path} не збережено; збережене перетворення ігнорується, оскільки Upright вимкнено.",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Геометрію Lightroom для {path} не збережено; збережене перетворення Guided Upright не підтримується.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Геометрію Lightroom для {path} не збережено; збережене перетворення Upright не підтримується.",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "Коригування XMP для {path} не імпортовано: {fields}.",
|
||||
"Imported {} photo{}": "Імпортовано фото: {}{:.0}",
|
||||
"Insert at the cursor (also written {})": "Вставити в позицію курсора (також записується як {})",
|
||||
"Label Set: {name}": "Набір міток: {name}",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "Об’єднання {what} скасовано",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)",
|
||||
"New Folder in “{name}”": "Нова тека в «{name}»",
|
||||
"Moved {} photo(s)": "Переміщено фото: {}"
|
||||
"Moved {} photo(s)": "Переміщено фото: {}",
|
||||
"not added to Apple Photos: {e}": "не додано до Apple Фото: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}",
|
||||
"{n} added to Apple Photos": "Додано до Apple Фото: {n}",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом"
|
||||
}
|
||||
@@ -2140,5 +2140,10 @@
|
||||
"Charcoal": "Графіт",
|
||||
"Midnight": "Опівніч",
|
||||
"Silver": "Срібло",
|
||||
"Paper": "Папір"
|
||||
"Paper": "Папір",
|
||||
"Add to Apple Photos": "Додати до Apple Фото",
|
||||
"Adding to Apple Photos…": "Додавання до Apple Фото…",
|
||||
"not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».",
|
||||
"Adding to Apple Photos": "Додавання до Apple Фото"
|
||||
}
|
||||
@@ -155,6 +155,13 @@
|
||||
"Folder “{}” (and its subfolders)": "文件夹“{0}”(含子文件夹)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 张缺失的照片{0:.0};仍有 {left} 张缺失{unsure}",
|
||||
"Import failed: {e}": "导入失败:{e}",
|
||||
"Import warnings ({n}):\n{warnings}": "导入警告({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "未对 {path} 应用 XMP 配置文件“{profile}”;颜色可能与 Lightroom 不同。",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "未保留 {path} 的 Lightroom 几何信息;将重新计算 {mode}。",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "未保留 {path} 的 Lightroom 几何信息;由于 Upright 已关闭,将忽略保存的变换。",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "未保留 {path} 的 Lightroom 几何信息;不支持保存的引导 Upright 变换。",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "未保留 {path} 的 Lightroom 几何信息;不支持保存的 Upright 变换。",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "未导入 {path} 的 XMP 调整:{fields}。",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已导入 {profiles} 个配置文件{0:.0}(配置文件浏览器 → 各自的分组)",
|
||||
"Insert at the cursor (also written {})": "在光标处插入(也可写作 {0})",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字体:{0}(OFL)。图标:原创。",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "已取消 {what} 合并",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)",
|
||||
"New Folder in “{name}”": "在“{name}”中新建文件夹",
|
||||
"Moved {} photo(s)": "已移动 {} 张照片"
|
||||
"Moved {} photo(s)": "已移动 {} 张照片",
|
||||
"not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”",
|
||||
"{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片",
|
||||
"{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起"
|
||||
}
|
||||
@@ -1658,96 +1658,96 @@
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
|
||||
"Open Log Folder": "打开日志文件夹",
|
||||
"Back to this person": "Back to this person",
|
||||
"AI Denoise": "AI Denoise",
|
||||
"AI Denoise Model": "AI Denoise Model",
|
||||
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
|
||||
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
|
||||
"Accept & Download": "Accept & Download",
|
||||
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
|
||||
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
|
||||
"Cache": "Cache",
|
||||
"Checking and installing the model…": "Checking and installing the model…",
|
||||
"Checking…": "Checking…",
|
||||
"Clear cache": "Clear cache",
|
||||
"Denoise Models": "Denoise Models",
|
||||
"Denoise Settings": "Denoise Settings",
|
||||
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
|
||||
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
|
||||
"Failed its last test": "Failed its last test",
|
||||
"Graphics card": "Graphics card",
|
||||
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
|
||||
"Inspect Denoise Model": "Inspect Denoise Model",
|
||||
"Install Denoise Model": "Install Denoise Model",
|
||||
"Install from file…": "Install from file…",
|
||||
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
|
||||
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
|
||||
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
|
||||
"Limit": "Limit",
|
||||
"Make it now": "Make it now",
|
||||
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
|
||||
"Next up for AI Denoise…": "Next up for AI Denoise…",
|
||||
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
|
||||
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
|
||||
"Open page": "Open page",
|
||||
"Open the model's page": "Open the model's page",
|
||||
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
|
||||
"Processor": "Processor",
|
||||
"Set up AI Denoise…": "Set up AI Denoise…",
|
||||
"Speed": "Speed",
|
||||
"Starting…": "Starting…",
|
||||
"Tested and works on this computer": "Tested and works on this computer",
|
||||
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
|
||||
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
|
||||
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
|
||||
"The processor does the work.": "The processor does the work.",
|
||||
"There is no model file to install": "There is no model file to install",
|
||||
"This build cannot run denoise models.": "This build cannot run denoise models.",
|
||||
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
|
||||
"This file cannot be used yet": "This file cannot be used yet",
|
||||
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
|
||||
"Try again": "Try again",
|
||||
"Try the graphics card again": "Try the graphics card again",
|
||||
"Turn off": "Turn off",
|
||||
"Unknown licence": "Unknown licence",
|
||||
"Use": "Use",
|
||||
"Waiting to start…": "Waiting to start…",
|
||||
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
|
||||
"cached picture": "cached picture",
|
||||
"cached pictures": "cached pictures",
|
||||
"commercial use allowed": "commercial use allowed",
|
||||
"Back to this person": "返回此人",
|
||||
"AI Denoise": "AI 降噪",
|
||||
"AI Denoise Model": "AI 降噪模型",
|
||||
"AI Denoise is off: press Use on a model.": "AI 降噪已关闭:请在某个模型上点击“使用”。",
|
||||
"AI Denoise needs a model. Install one in Settings.": "AI 降噪需要模型。请在设置中安装一个。",
|
||||
"Accept & Download": "接受并下载",
|
||||
"Already installed. Remove it before installing a replacement.": "已安装。安装替代模型前请先将其移除。",
|
||||
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "自动模式会在显卡快于处理器时使用显卡:两者都会在首次生成照片时于本机计时比较。",
|
||||
"Cache": "缓存",
|
||||
"Checking and installing the model…": "正在检查并安装模型…",
|
||||
"Checking…": "正在检查…",
|
||||
"Clear cache": "清除缓存",
|
||||
"Denoise Models": "降噪模型",
|
||||
"Denoise Settings": "降噪设置",
|
||||
"Denoise all photos with an Amount": "对所有设置了数量的照片进行降噪",
|
||||
"Downloaded and checked. Installing…": "已下载并校验完成。正在安装…",
|
||||
"Failed its last test": "上次检测未通过",
|
||||
"Graphics card": "显卡",
|
||||
"I have read these terms and accept them for my own use": "我已阅读这些条款,并为个人使用表示接受",
|
||||
"Inspect Denoise Model": "查看降噪模型",
|
||||
"Install Denoise Model": "安装降噪模型",
|
||||
"Install from file…": "从文件安装…",
|
||||
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "请自行安装 ONNX 模型,并在其旁边放置 denoise-model.json。此版本不提供模型下载。",
|
||||
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "该照片的降噪结果尚未生成,而且不会自动生成(设置、AI 降噪)。",
|
||||
"LightCraft does not know this model, so it assumed:": "LightCraft 不认识此模型,因此按以下假设处理:",
|
||||
"Limit": "上限",
|
||||
"Make it now": "立即生成",
|
||||
"Make the denoised picture of the photos I am looking at": "为我正在查看的照片生成降噪结果",
|
||||
"Next up for AI Denoise…": "接下来的 AI 降噪任务…",
|
||||
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "在你接受之前不会下载任何内容。模型仅保存在本机;LightCraft 绝不会上传或分享它。",
|
||||
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "只对 AI 降噪数量大于 0 的照片生成结果。导出时若缺少结果则会先生成。",
|
||||
"Open page": "打开页面",
|
||||
"Open the model's page": "打开该模型的页面",
|
||||
"Opens the model's own page in your browser, to read about it or get the file yourself.": "在浏览器中打开该模型的主页,以便查阅说明或自行获取文件。",
|
||||
"Processor": "处理器",
|
||||
"Set up AI Denoise…": "设置 AI 降噪…",
|
||||
"Speed": "速度",
|
||||
"Starting…": "正在启动…",
|
||||
"Tested and works on this computer": "已在本机测试通过并可正常使用",
|
||||
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "降噪模型已安装并正在使用:细节面板中的 AI 降噪数量滑块现已可用",
|
||||
"The graphics card is set up when the next photo is made.": "将在生成下一张照片时初始化显卡。",
|
||||
"The model is kept on this computer only. LightCraft never uploads or shares it.": "模型仅保存在本机。LightCraft 绝不会上传或分享它。",
|
||||
"The processor does the work.": "由处理器完成运算。",
|
||||
"There is no model file to install": "没有可安装的模型文件",
|
||||
"This build cannot run denoise models.": "此版本无法运行降噪模型。",
|
||||
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "此版本没有存放降噪模型的位置,因此无法使用 AI 降噪。(桌面版可以。)",
|
||||
"This file cannot be used yet": "此文件暂时无法使用",
|
||||
"Tick the box to accept the model's terms first": "请先勾选以接受该模型的条款",
|
||||
"Try again": "重试",
|
||||
"Try the graphics card again": "重新尝试使用显卡",
|
||||
"Turn off": "关闭",
|
||||
"Unknown licence": "许可证未知",
|
||||
"Use": "使用",
|
||||
"Waiting to start…": "等待启动…",
|
||||
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "适用于采用标准(拜耳)传感器的相机 RAW 文件。其他文件仍使用常规降噪。",
|
||||
"cached picture": "张已缓存的照片",
|
||||
"cached pictures": "张已缓存的照片",
|
||||
"commercial use allowed": "允许商用",
|
||||
"denoiseModel.accept": "denoiseModel.accept",
|
||||
"in use": "in use",
|
||||
"installed": "installed",
|
||||
"makes AI Denoise available": "makes AI Denoise available",
|
||||
"non-commercial use only": "non-commercial use only",
|
||||
"photo has its denoised picture.": "photo has its denoised picture.",
|
||||
"photo waiting": "photo waiting",
|
||||
"photos have their denoised picture.": "photos have their denoised picture.",
|
||||
"photos waiting": "photos waiting",
|
||||
"starts using it for AI Denoise": "starts using it for AI Denoise",
|
||||
"terms unclear": "terms unclear",
|
||||
"the graphics card": "the graphics card",
|
||||
"the graphics card is not used": "the graphics card is not used",
|
||||
"Cancel Denoise": "Cancel Denoise",
|
||||
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
|
||||
"Choose Denoise Model": "Choose Denoise Model",
|
||||
"Clear Denoise Cache": "Clear Denoise Cache",
|
||||
"Denoise Model Downloads": "Denoise Model Downloads",
|
||||
"Denoise Photos": "Denoise Photos",
|
||||
"Denoise Pump": "Denoise Pump",
|
||||
"Denoise Status": "Denoise Status",
|
||||
"Download Denoise Model": "Download Denoise Model",
|
||||
"Remove Denoise Model": "Remove Denoise Model",
|
||||
"Test Denoise Model": "Test Denoise Model",
|
||||
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
|
||||
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
|
||||
"Cancel pending action": "Cancel pending action",
|
||||
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
|
||||
"Pending photo action cancelled": "Pending photo action cancelled",
|
||||
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
|
||||
"ONNX model": "ONNX model",
|
||||
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
|
||||
"in use": "正在使用",
|
||||
"installed": "已安装",
|
||||
"makes AI Denoise available": "以启用 AI 降噪",
|
||||
"non-commercial use only": "仅限非商用",
|
||||
"photo has its denoised picture.": "张照片已有降噪结果。",
|
||||
"photo waiting": "张照片等待中",
|
||||
"photos have their denoised picture.": "张照片已有降噪结果。",
|
||||
"photos waiting": "张照片等待中",
|
||||
"starts using it for AI Denoise": "开始用它进行 AI 降噪",
|
||||
"terms unclear": "条款不明确",
|
||||
"the graphics card": "显卡",
|
||||
"the graphics card is not used": "未使用显卡",
|
||||
"Cancel Denoise": "取消降噪",
|
||||
"Cancel Denoise Model Download": "取消降噪模型下载",
|
||||
"Choose Denoise Model": "选择降噪模型",
|
||||
"Clear Denoise Cache": "清除降噪缓存",
|
||||
"Denoise Model Downloads": "降噪模型下载",
|
||||
"Denoise Photos": "照片降噪",
|
||||
"Denoise Pump": "降噪队列",
|
||||
"Denoise Status": "降噪状态",
|
||||
"Download Denoise Model": "下载降噪模型",
|
||||
"Remove Denoise Model": "移除降噪模型",
|
||||
"Test Denoise Model": "测试降噪模型",
|
||||
"AI Denoise is enabled on the requesting photo": "已在发起请求的照片上启用 AI 降噪",
|
||||
"After installation, AI Denoise will turn on for the requesting photo.": "安装完成后,将为发起请求的照片开启 AI 降噪。",
|
||||
"Cancel pending action": "取消待处理操作",
|
||||
"Install a denoise model to enable AI Denoise on this photo": "安装降噪模型后即可在此照片上启用 AI 降噪",
|
||||
"Pending photo action cancelled": "已取消待处理的照片操作",
|
||||
"Pending photo action cancelled because its library or photo changed": "待处理的照片操作已取消,因为其所属图库或照片发生了变化",
|
||||
"ONNX model": "ONNX 模型",
|
||||
"commercial use subject to GPL conditions": "商用须遵循 GPL 条款",
|
||||
"Decrease Rating": "降低星级",
|
||||
"Increase Rating": "提高星级",
|
||||
"Click to type a value": "单击以输入数值",
|
||||
@@ -1946,5 +1946,48 @@
|
||||
"Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖",
|
||||
"Appearance Settings…": "外观设置…",
|
||||
"Add to favorites": "添加到收藏",
|
||||
"Remove from favorites": "从收藏中移除"
|
||||
"Remove from favorites": "从收藏中移除",
|
||||
"Add Face Model…": "添加人脸模型…",
|
||||
"Cancel Face Model Download": "取消人脸模型下载",
|
||||
"Cancel Pending Photo Action": "取消待处理的照片操作",
|
||||
"Choose Face Recognition Model": "选择人脸识别模型",
|
||||
"Choose a model below (Use) to start.": "请在下方选择模型(点击“使用”)以开始。",
|
||||
"Detect Faces": "检测人脸",
|
||||
"Download Face Model": "下载人脸模型",
|
||||
"Drag Crop Handle": "拖动裁切手柄",
|
||||
"Evaluate Face Recognition": "评估人脸识别",
|
||||
"Face Model Downloads": "人脸模型下载",
|
||||
"Face Models": "人脸模型",
|
||||
"Face Recognition On/Off": "人脸识别开关",
|
||||
"Face detector": "人脸检测器",
|
||||
"Face recognition": "人脸识别",
|
||||
"Faces": "人脸",
|
||||
"Faces of a Person": "某个人的人脸",
|
||||
"Go back to listing them by name": "返回按名称列出",
|
||||
"Index Faces": "索引人脸",
|
||||
"Index Faces in the Background": "在后台索引人脸",
|
||||
"Inspect Face Model File": "查看人脸模型文件",
|
||||
"Install Face Model": "安装人脸模型",
|
||||
"Library Folders": "图库文件夹",
|
||||
"Name Face": "命名人脸",
|
||||
"Name Faces": "命名人脸",
|
||||
"Recognise faces (experimental)": "识别面部(实验功能)",
|
||||
"Recognition models": "识别模型",
|
||||
"Remove Face Model": "移除人脸模型",
|
||||
"Reorder Album": "重排相册",
|
||||
"Show Person": "显示人物",
|
||||
"Sort Albums A–Z": "相册按 A–Z 排序",
|
||||
"Sort Albums by Name": "相册按名称排序",
|
||||
"Sort Contents A–Z": "内容按 A–Z 排序",
|
||||
"Suggest Names for Faces": "为人脸建议名称",
|
||||
"Test Face Model": "测试人脸模型",
|
||||
"Unnamed Faces": "未命名人脸",
|
||||
"save goes through.": "才能完成保存。",
|
||||
"smart album value|No": "智能相册取值|否",
|
||||
"smart album value|Yes": "智能相册取值|是",
|
||||
"Add to Apple Photos": "添加到 Apple 照片",
|
||||
"Adding to Apple Photos…": "正在添加到 Apple 照片…",
|
||||
"not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。",
|
||||
"Adding to Apple Photos": "正在添加到 Apple 照片"
|
||||
}
|
||||
@@ -155,6 +155,13 @@
|
||||
"Folder “{}” (and its subfolders)": "資料夾「{}」(含子資料夾)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 張遺失的照片{:.0};仍有 {left} 張遺失{unsure}",
|
||||
"Import failed: {e}": "匯入失敗:{e}",
|
||||
"Import warnings ({n}):\n{warnings}": "匯入警告({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "未將 XMP 描述檔「{profile}」套用至 {path};色彩可能與 Lightroom 不同。",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "未保留 {path} 的 Lightroom 幾何資料;將重新計算 {mode}。",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "未保留 {path} 的 Lightroom 幾何資料;Upright 已關閉,因此忽略儲存的轉換。",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "未保留 {path} 的 Lightroom 幾何資料;不支援儲存的引導 Upright 轉換。",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "未保留 {path} 的 Lightroom 幾何資料;不支援儲存的 Upright 轉換。",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "未匯入 {path} 的 XMP 調整:{fields}。",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已匯入 {profiles} 個描述檔{:.0}(描述檔瀏覽器 → 各自的群組)",
|
||||
"Insert at the cursor (also written {})": "在游標處插入(也可寫成 {})",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字型:{}(OFL)。圖示:原創。",
|
||||
@@ -233,5 +240,9 @@
|
||||
"{what} merge cancelled": "已取消{what}合併",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)",
|
||||
"New Folder in “{name}”": "在「{name}」中新增資料夾",
|
||||
"Moved {} photo(s)": "已移動 {} 張照片"
|
||||
"Moved {} photo(s)": "已移動 {} 張照片",
|
||||
"not added to Apple Photos: {e}": "未加入 Apple 照片:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」",
|
||||
"{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片",
|
||||
"{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起"
|
||||
}
|
||||
@@ -1946,5 +1946,10 @@
|
||||
"Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫",
|
||||
"Appearance Settings…": "外觀設定…",
|
||||
"Add to favorites": "加入最愛",
|
||||
"Remove from favorites": "從最愛移除"
|
||||
"Remove from favorites": "從最愛移除",
|
||||
"Add to Apple Photos": "加入 Apple 照片",
|
||||
"Adding to Apple Photos…": "正在加入 Apple 照片…",
|
||||
"not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。",
|
||||
"Adding to Apple Photos": "正在加入 Apple 照片"
|
||||
}
|
||||
@@ -494,13 +494,23 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
|
||||
return Err(NO_EXPORT_FOLDER.into());
|
||||
}
|
||||
let to = Destination { dir: dir.clone(), exact };
|
||||
// Add to Apple Photos: refused here, before anything is written, when it can't be done
|
||||
let after = app.session.after_export(&opts)?;
|
||||
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false) && app.services.write_shared.is_some();
|
||||
let out = if background {
|
||||
let items = lightcraft_engine::export::prepare_batch(&mut app.session, &ids, &opts)?;
|
||||
crate::export_task::start(app, items, opts, to)?
|
||||
crate::export_task::start(app, items, opts, to, after)?
|
||||
} else {
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
json!({"files": export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?})
|
||||
let files = export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?;
|
||||
let mut out = json!({"files": files});
|
||||
// Photos may take minutes (and waits for the permission prompt): in the desktop app on a
|
||||
// worker, never on this (UI) thread; the app announces the outcome and
|
||||
// `export.photosImports` reports it. A window-less run (snapshot scripts) waits.
|
||||
if let Some(photos) = after.finish(&app.session, &files) {
|
||||
out["applePhotos"] = photos;
|
||||
}
|
||||
out
|
||||
};
|
||||
// remember for Export with Previous (and to prefill the dialog)
|
||||
let mut last = p.clone();
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_engine::activity::{Cancel, TaskGuard};
|
||||
use lightcraft_engine::export::{Destination, ExportOptions, PreparedExport, run_batch};
|
||||
use lightcraft_engine::export::{AfterExport, Destination, ExportOptions, PreparedExport, run_batch};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
@@ -22,7 +22,9 @@ pub struct ExportTask {
|
||||
/// Photos started so far and the file in progress.
|
||||
pub progress: Arc<Mutex<(usize, String)>>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
rx: Receiver<Result<Vec<Value>, String>>,
|
||||
/// The files (per-photo results), what [`AfterExport`] did with them (`applePhotos`) and
|
||||
/// whether the export was cancelled (as the worker saw it when the batch ended).
|
||||
rx: Receiver<Result<(Vec<Value>, Option<Value>, bool), String>>,
|
||||
/// The export's row in the activity stack (`activity.cancel` sets `cancel`).
|
||||
guard: TaskGuard,
|
||||
}
|
||||
@@ -35,8 +37,9 @@ impl ExportTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start exporting `items` in the background. Errors per photo are collected, not fatal.
|
||||
pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination) -> Result<Value, String> {
|
||||
/// Start exporting `items` in the background, then do `after` with the files written (Add to Apple
|
||||
/// Photos). Errors per photo are collected, not fatal.
|
||||
pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination, after: AfterExport) -> Result<Value, String> {
|
||||
if app.export.is_some() {
|
||||
return Err("an export is already running".into());
|
||||
}
|
||||
@@ -46,6 +49,11 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = channel();
|
||||
let (p, c) = (progress.clone(), cancel.clone());
|
||||
let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
|
||||
guard.progress(0, total as u64);
|
||||
let row = guard.handle();
|
||||
// translated here: the language is the UI thread's, and the worker would show English
|
||||
let adding = crate::i18n::tr("Adding to Apple Photos…").to_string();
|
||||
let work = move || {
|
||||
let mut w = |path: &str, bytes: &[u8]| write(path, bytes);
|
||||
let r = run_batch(items, &opts, &to, &mut w, &|path| std::path::Path::new(path).exists(), false, &mut |done, name| {
|
||||
@@ -54,14 +62,25 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
|
||||
}
|
||||
!c.load(Ordering::Relaxed)
|
||||
});
|
||||
let r = r.map(|files| {
|
||||
if !after.is_none() {
|
||||
// Photos can't be stopped once asked: no ✕ from here, before the last look at
|
||||
// the cancel flag, so a cancel either stops the export before Photos or is refused
|
||||
row.set_cancellable(false);
|
||||
if let Ok(mut g) = p.lock() {
|
||||
g.1 = adding;
|
||||
}
|
||||
}
|
||||
let cancelled = c.load(Ordering::Relaxed);
|
||||
let photos = after.run(&files, cancelled);
|
||||
(files, photos, cancelled)
|
||||
});
|
||||
let _ = tx.send(r);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
|
||||
guard.progress(0, total as u64);
|
||||
app.export = Some(ExportTask { total, progress, cancel, rx, guard });
|
||||
Ok(json!({"background": true, "total": total}))
|
||||
}
|
||||
@@ -100,7 +119,8 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
|
||||
}
|
||||
Ok(vec![json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len(), "contactSheet": true})])
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
// a PDF isn't something Photos imports: contact sheets never add to Apple Photos
|
||||
let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed))));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::Builder::new().name("contact-sheet".into()).spawn(work).map_err(|e| e.to_string())?;
|
||||
@@ -112,16 +132,65 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
|
||||
Ok(json!({"background": true, "total": total}))
|
||||
}
|
||||
|
||||
/// The toast's account of [`AfterExport::run`]'s Apple Photos outcome.
|
||||
pub fn apple_photos_note(outcome: &Value) -> String {
|
||||
if let Some(e) = outcome.get("error").and_then(Value::as_str) {
|
||||
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = e);
|
||||
}
|
||||
if let Some(why) = outcome.get("skipped").and_then(Value::as_str) {
|
||||
if why.contains("cancelled") {
|
||||
return crate::i18n::tr("not added to Apple Photos (cancelled)").to_string();
|
||||
}
|
||||
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = why);
|
||||
}
|
||||
let n = outcome.get("imported").and_then(Value::as_u64).unwrap_or(0);
|
||||
let mut m = match outcome.get("album").and_then(Value::as_str) {
|
||||
Some(album) => crate::i18n::tr_format!("{n} added to Apple Photos album “{album}”", n = n, album = album),
|
||||
None => crate::i18n::tr_format!("{n} added to Apple Photos", n = n),
|
||||
};
|
||||
if let Some(w) = outcome.get("warning").and_then(Value::as_str) {
|
||||
m += &format!(" ({w})");
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Per frame: when an Apple Photos import that no export task reports finishes (`app.export`
|
||||
/// without `background`, `export.addToPhotos`), say how it went.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let jobs = app.session.apple_photos_imports.all();
|
||||
if jobs.iter().any(|j| !j.finished()) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
for job in jobs {
|
||||
if job.take_announcement() {
|
||||
let outcome = job.json();
|
||||
let msg = apple_photos_note(&outcome);
|
||||
if outcome.get("error").is_some() {
|
||||
app.toast_for(ctx, msg, 15.0);
|
||||
} else {
|
||||
app.toast(ctx, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No Apple Photos on the web.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {}
|
||||
|
||||
/// Per frame: keep the activity row up to date; when the batch finishes, report it.
|
||||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = &app.export else { return };
|
||||
match task.rx.try_recv() {
|
||||
Ok(r) => {
|
||||
let cancelled = task.cancel.load(Ordering::Relaxed);
|
||||
let cancelled = matches!(&r, Ok((_, _, true)));
|
||||
let total = task.total;
|
||||
app.export = None;
|
||||
// Photos refused (permission, not installed…): leave the way out on screen longer
|
||||
let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());
|
||||
let msg = match r {
|
||||
Ok(files) => {
|
||||
Ok((files, photos, _)) => {
|
||||
let sheet = files.first().filter(|f| f["contactSheet"] == true);
|
||||
let ok = files.iter().filter(|f| f.get("path").is_some()).count();
|
||||
let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
|
||||
@@ -140,12 +209,20 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if cancelled {
|
||||
m += " · cancelled";
|
||||
}
|
||||
app.last_export_result = Some(json!({"files": files, "cancelled": cancelled}));
|
||||
if let Some(photos) = &photos {
|
||||
m += " · ";
|
||||
m += &apple_photos_note(photos);
|
||||
}
|
||||
app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));
|
||||
m
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
app.toast(ctx, msg);
|
||||
if photos_failed {
|
||||
app.toast_for(ctx, msg, 15.0);
|
||||
} else {
|
||||
app.toast(ctx, msg);
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
|
||||
@@ -248,3 +325,24 @@ mod contact_sheet_tests {
|
||||
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The toast's Apple Photos part (issue #236): the count and album, a partial import's
|
||||
/// warning, an error with what to do, a cancelled export.
|
||||
#[test]
|
||||
fn apple_photos_outcomes_read_well() {
|
||||
assert_eq!(apple_photos_note(&json!({"imported": 3, "requested": 3, "ids": [], "album": null})), "3 added to Apple Photos");
|
||||
assert_eq!(apple_photos_note(&json!({"imported": 1, "album": "Trip"})), "1 added to Apple Photos album “Trip”");
|
||||
let partial = apple_photos_note(&json!({"imported": 1, "requested": 2, "warning": "Photos added 1 of 2 files"}));
|
||||
assert_eq!(partial, "1 added to Apple Photos (Photos added 1 of 2 files)");
|
||||
let denied = apple_photos_note(
|
||||
&json!({"error": "LightCraft isn't allowed to control Photos. Allow it in System Settings › Privacy & Security › Automation"}),
|
||||
);
|
||||
assert!(denied.starts_with("not added to Apple Photos: ") && denied.contains("Automation"), "{denied}");
|
||||
assert_eq!(apple_photos_note(&json!({"skipped": "the export was cancelled"})), "not added to Apple Photos (cancelled)");
|
||||
assert_eq!(apple_photos_note(&json!({"skipped": "the export wrote no files"})), "not added to Apple Photos: the export wrote no files");
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,16 @@ pub struct Headless {
|
||||
pub last_cursor: egui::CursorIcon,
|
||||
}
|
||||
|
||||
/// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it
|
||||
/// first waits for an Apple Photos import its script left running (`export.addToPhotos {wait:
|
||||
/// false}`), bounded, so it never abandons osascript or the import's result.
|
||||
impl Drop for Headless {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.app.session.wait_for_photos_imports();
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
/// Wrap `app` (its control channel is replaced by the driver's).
|
||||
/// Invalid dimensions fall back to 1600×1000 at scale 1; external callers can use
|
||||
@@ -1840,6 +1850,276 @@ mod tests {
|
||||
assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64));
|
||||
}
|
||||
|
||||
/// Issue #236: the Export dialog's Add to Apple Photos is there on macOS only; there the
|
||||
/// background export hands the files it wrote to Photos (a fake runner: tests never start
|
||||
/// osascript or Photos) and the toast says how it went.
|
||||
#[test]
|
||||
fn export_dialog_adds_to_apple_photos_on_macos() {
|
||||
let mut h = demo([1200.0, 1100.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-dialog-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
|
||||
let calls = Arc::new(std::sync::Mutex::new(Vec::<Vec<String>>::new()));
|
||||
let c = calls.clone();
|
||||
h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "A/L0/001\nB/L0/001\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t);
|
||||
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
|
||||
h.settle(SETTLE);
|
||||
let shown = h.request("ui.widgets", json!({"filter": "exportAddToPhotos"}), t)["result"].as_array().map_or(0, Vec::len);
|
||||
assert_eq!(shown > 0, cfg!(target_os = "macos"), "the option is offered where Photos is");
|
||||
if !cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
h.request("ui.clickWidget", json!({"id": "exportAddToPhotos"}), t);
|
||||
h.settle(SETTLE);
|
||||
if let Some(crate::state::Dialog::Export { opts, full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
|
||||
assert!(opts.add_to_photos, "the checkbox turns it on");
|
||||
opts.photos_album = "Trip “2026”".into();
|
||||
*full_size = false;
|
||||
*resize = lightcraft_engine::export::Resize::long_edge(48);
|
||||
*dir = out.to_string_lossy().into_owned();
|
||||
}
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let t0 = Instant::now();
|
||||
while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) {
|
||||
h.step();
|
||||
}
|
||||
let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone();
|
||||
let written: Vec<String> = last["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(written.len(), 2, "{last}");
|
||||
assert_eq!(last["applePhotos"]["imported"], 2, "{last}");
|
||||
assert_eq!(last["applePhotos"]["album"], "Trip “2026”");
|
||||
let args = calls.lock().unwrap().clone();
|
||||
assert_eq!(args.len(), 1);
|
||||
assert_eq!(args[0][args[0].len() - 2..], written[..], "the files written go to Photos");
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains("2 added to Apple Photos album “Trip “2026””"), "{toast}");
|
||||
// Export with Previous repeats it
|
||||
assert_eq!(h.app.session.last_export.as_ref().unwrap()["addToPhotos"], true);
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Codex review of #643: the progress line while Photos imports was translated on the export's
|
||||
/// worker thread, whose language is always English.
|
||||
#[test]
|
||||
fn the_photos_progress_line_is_in_the_users_language() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-language-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
h.app.ui.language = crate::i18n::Locale::De;
|
||||
crate::i18n::set_language(crate::i18n::Locale::De);
|
||||
h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
|
||||
let (release, held) = std::sync::mpsc::channel::<()>();
|
||||
let held = std::sync::Mutex::new(held);
|
||||
let (entered, photos_asked) = std::sync::mpsc::channel::<()>();
|
||||
let entered = std::sync::Mutex::new(entered);
|
||||
h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| {
|
||||
let _ = entered.lock().unwrap().send(());
|
||||
let _ = held.lock().unwrap().recv_timeout(Duration::from_secs(30));
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(1).map(|p| p.0).collect();
|
||||
let params = json!({"ids": ids, "dir": out, "longEdge": 48, "addToPhotos": true, "background": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": params}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
// Photos has been asked, and the window hasn't drawn a frame since (Codex review of #643):
|
||||
// the export's row already has no ✕, and a cancel is refused rather than half-honoured
|
||||
photos_asked.recv_timeout(Duration::from_secs(60)).expect("the export asks Photos");
|
||||
let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row");
|
||||
assert!(!row.cancellable, "{row:?}");
|
||||
assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked");
|
||||
let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default();
|
||||
assert_eq!(current, "Wird zu Apple Fotos hinzugefügt…");
|
||||
// the row says so on the next frame (issue #345)
|
||||
h.step();
|
||||
let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row");
|
||||
assert_eq!((row.detail.as_str(), row.cancellable), ("Wird zu Apple Fotos hinzugefügt…", false), "{row:?}");
|
||||
release.send(()).unwrap();
|
||||
let t0 = Instant::now();
|
||||
while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(90) {
|
||||
h.step();
|
||||
}
|
||||
let last = h.app.last_export_result.clone().expect("the export reports");
|
||||
assert_eq!((last["cancelled"].as_bool(), last["applePhotos"]["imported"].as_u64()), (Some(false), Some(1)), "{last}");
|
||||
crate::i18n::set_language(crate::i18n::Locale::En);
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Issue #236 review: control requests run on the UI thread, which must not wait minutes for
|
||||
/// Photos. `export.addToPhotos` and `app.export {addToPhotos}` without `background` answer at
|
||||
/// once with the running job; the app announces the outcome when Photos is done.
|
||||
#[test]
|
||||
fn a_slow_photos_import_never_blocks_the_window() {
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(30);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-slow-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let file = out.join("a.jpg");
|
||||
std::fs::write(&file, b"jpeg").unwrap();
|
||||
h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = std::sync::Mutex::new(wait);
|
||||
h.app.session.apple_photos_background = true; // as the desktop app sets it
|
||||
h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
// a regression (Photos on the UI thread) fails the timing below instead of hanging
|
||||
let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
let n = args.len() - args.iter().position(|a| a == lightcraft_engine::apple_photos::MARKER).unwrap() - 2;
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() })
|
||||
}));
|
||||
let t0 = Instant::now();
|
||||
let r =
|
||||
h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t);
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}");
|
||||
assert_eq!(r["result"]["running"], true, "{r}");
|
||||
go.send(()).unwrap();
|
||||
while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Apple Photos")) {
|
||||
assert!(t0.elapsed() < Duration::from_secs(20), "the outcome is announced");
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.ui.toast.as_ref().unwrap().0, "1 added to Apple Photos album “Slow”");
|
||||
if cfg!(target_os = "macos") {
|
||||
let t1 = Instant::now();
|
||||
let p = json!({"dir": out.to_string_lossy(), "longEdge": 32, "addToPhotos": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert!(t1.elapsed() < Duration::from_secs(10), "the export answers without waiting for Photos: {r}");
|
||||
assert_eq!(r["result"]["applePhotos"]["running"], true, "{r}");
|
||||
assert_eq!(r["result"]["applePhotos"]["job"], 2, "{r}");
|
||||
go.send(()).unwrap();
|
||||
while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("1 added to Apple Photos") && !t.0.contains("Slow")) {
|
||||
assert!(t1.elapsed() < Duration::from_secs(20), "the outcome is announced");
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Review of #236: a headless run (snapshot scripts) ending while an import it started with
|
||||
/// `wait: false` is still running waits for it, on every exit path (dropping the `Headless`).
|
||||
#[test]
|
||||
fn a_headless_run_waits_for_its_photos_import_before_ending() {
|
||||
let mut h = demo([800.0, 600.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-photos-headless-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("a.jpg");
|
||||
std::fs::write(&file, b"jpeg").unwrap();
|
||||
let marker = dir.join("imported");
|
||||
let m = marker.clone();
|
||||
// Photos "imports" when `go` arrives (at most 20 s): released 0.8 s into the drop below
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = std::sync::Mutex::new(wait);
|
||||
h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| {
|
||||
let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
std::fs::write(&m, b"done").unwrap();
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let r =
|
||||
h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t);
|
||||
assert_eq!(r["result"]["running"], true, "{r}");
|
||||
assert!(!marker.exists(), "still importing");
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
let _ = go.send(());
|
||||
});
|
||||
drop(h);
|
||||
assert!(marker.exists(), "the import finished before the run ended");
|
||||
release.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #236: a snapshot that ends (an error in its script, say) while its background
|
||||
/// export is still writing waits through the writing and then the import the export starts.
|
||||
#[test]
|
||||
fn a_headless_run_waits_for_its_exports_photos_import() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return; // addToPhotos is refused before exporting elsewhere
|
||||
}
|
||||
let mut h = demo([800.0, 600.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-photos-headless-export-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let marker = dir.join("imported");
|
||||
h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| {
|
||||
std::thread::sleep(Duration::from_millis(300)); // a slow disk: still writing at the drop
|
||||
lightcraft_engine::export::write_file(p, bytes)
|
||||
}));
|
||||
let m = marker.clone();
|
||||
h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::fs::write(&m, b"done").unwrap();
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let p = json!({"dir": dir.join("out").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "background": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.session.apple_photos_imports.running().is_some(), "reserved while writing");
|
||||
drop(h);
|
||||
assert!(marker.exists(), "the export's import finished before the run ended");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #236: a background export adding to Photos holds the import from before it
|
||||
/// writes. While its files are being written, `export.addToPhotos` is refused with the
|
||||
/// export's job number; the export then adds its files under that job.
|
||||
#[test]
|
||||
fn a_background_export_holds_photos_while_it_writes() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return; // addToPhotos is refused before exporting elsewhere
|
||||
}
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-hold-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let other = out.join("other.jpg");
|
||||
std::fs::write(&other, b"jpeg").unwrap();
|
||||
// the writer waits for `go`: the export is still writing while we try another import
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = std::sync::Mutex::new(wait);
|
||||
h.app.services.write_shared = Some(Arc::new(move |p: &str, bytes: &[u8]| {
|
||||
let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
lightcraft_engine::export::write_file(p, bytes)
|
||||
}));
|
||||
let calls = Arc::new(std::sync::Mutex::new(Vec::<Vec<String>>::new()));
|
||||
let c = calls.clone();
|
||||
h.app.session.apple_photos_background = true;
|
||||
h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let p = json!({"dir": out.join("export").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "Held", "background": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.export.is_some(), "writing in the background");
|
||||
let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [other.to_string_lossy()]}}), t);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().unwrap().contains("An export is about to add its files to Apple Photos (import 1)"), "{r}");
|
||||
go.send(()).unwrap();
|
||||
let t0 = Instant::now();
|
||||
while h.app.export.is_some() {
|
||||
assert!(t0.elapsed() < Duration::from_secs(20), "the export finishes");
|
||||
h.step();
|
||||
}
|
||||
let last = h.app.last_export_result.clone().unwrap();
|
||||
assert_eq!((last["applePhotos"]["job"].as_u64(), last["applePhotos"]["imported"].as_u64()), (Some(1), Some(1)), "{last}");
|
||||
let args = calls.lock().unwrap().clone();
|
||||
assert_eq!(args.len(), 1, "only the export's files went to Photos");
|
||||
assert!(args[0].last().unwrap().contains("export"), "{:?}", args[0].last());
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Dragging grid photos onto an album row adds them to the album.
|
||||
#[test]
|
||||
fn drag_photos_onto_an_album() {
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! one undo step.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
|
||||
use lightcraft_engine::import::{
|
||||
ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -240,6 +242,8 @@ pub struct ImportTask {
|
||||
/// Move: originals moved, and sources left in place (reported by the engine with a reason).
|
||||
pub moved: usize,
|
||||
pub kept: usize,
|
||||
/// Structured diagnostics from foreign XMP develop settings.
|
||||
pub warnings: Vec<ImportWarning>,
|
||||
undo0: usize,
|
||||
first: Option<u64>,
|
||||
/// Reading a folder for the Local view: the photos stay out of the library, and nothing is
|
||||
@@ -722,6 +726,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
|
||||
task.failed += len("failed");
|
||||
task.moved += len("moved");
|
||||
task.kept += len("kept");
|
||||
task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok()));
|
||||
for k in v["kept"].as_array().into_iter().flatten() {
|
||||
log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or(""));
|
||||
}
|
||||
@@ -736,6 +741,61 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
|
||||
}
|
||||
}
|
||||
|
||||
fn xmp_warning_text(warning: &ImportWarning) -> String {
|
||||
let path = &warning.path;
|
||||
match warning.code {
|
||||
ImportWarningCode::CameraProfile => crate::i18n::tr_format!(
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.",
|
||||
profile = warning.profile.as_deref().unwrap_or_default(),
|
||||
path = path,
|
||||
),
|
||||
ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) {
|
||||
ImportUprightMode::Off => crate::i18n::tr_format!(
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.",
|
||||
path = path,
|
||||
),
|
||||
ImportUprightMode::Guided => crate::i18n::tr_format!(
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.",
|
||||
path = path,
|
||||
),
|
||||
ImportUprightMode::Auto => crate::i18n::tr_format!(
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
||||
path = path,
|
||||
mode = crate::i18n::tr("Auto")
|
||||
),
|
||||
ImportUprightMode::Level => crate::i18n::tr_format!(
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
||||
path = path,
|
||||
mode = crate::i18n::tr("Level")
|
||||
),
|
||||
ImportUprightMode::Vertical => crate::i18n::tr_format!(
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
||||
path = path,
|
||||
mode = crate::i18n::tr("Vertical")
|
||||
),
|
||||
ImportUprightMode::Full => crate::i18n::tr_format!(
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
||||
path = path,
|
||||
mode = crate::i18n::tr("Full")
|
||||
),
|
||||
ImportUprightMode::Unknown => {
|
||||
crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,)
|
||||
}
|
||||
},
|
||||
ImportWarningCode::UnmappedXmp => {
|
||||
crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn xmp_warnings_text(warnings: &[ImportWarning]) -> String {
|
||||
crate::i18n::tr_format!(
|
||||
"Import warnings ({n}):\n{warnings}",
|
||||
n = warnings.len(),
|
||||
warnings = warnings.iter().map(xmp_warning_text).collect::<Vec<_>>().join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The import is done (or cancelled): one undo step, select the first photo, say what happened.
|
||||
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
let steps = app.session.undo.len().saturating_sub(task.undo0);
|
||||
@@ -749,13 +809,17 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
};
|
||||
app.session.merge_undo(steps, &label);
|
||||
if task.auto {
|
||||
if task.imported > 0 {
|
||||
if !task.warnings.is_empty() {
|
||||
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
|
||||
} else if task.imported > 0 {
|
||||
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if task.browse {
|
||||
if task.failed > 0 {
|
||||
if !task.warnings.is_empty() {
|
||||
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
|
||||
} else if task.failed > 0 {
|
||||
app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" }));
|
||||
}
|
||||
return;
|
||||
@@ -766,7 +830,13 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
// nothing new, only files the library already has: show where they are (Recently Deleted is
|
||||
// easy to miss, and the side panel that lists it starts collapsed)
|
||||
if task.imported == 0 && task.restored == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) {
|
||||
if task.imported == 0
|
||||
&& task.restored == 0
|
||||
&& task.failed == 0
|
||||
&& task.warnings.is_empty()
|
||||
&& !task.cancelled
|
||||
&& show_existing(app, ctx, &task.existing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut msg = if task.cancelled {
|
||||
@@ -795,7 +865,13 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
if task.failed > 0 {
|
||||
msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));
|
||||
}
|
||||
app.toast(ctx, msg);
|
||||
if task.warnings.is_empty() {
|
||||
app.toast(ctx, msg);
|
||||
} else {
|
||||
msg.push_str("\n\n");
|
||||
msg.push_str(&xmp_warnings_text(&task.warnings));
|
||||
app.toast_for(ctx, msg, 8.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos among `ids` that are in Recently Deleted (each once).
|
||||
|
||||
@@ -1312,6 +1312,7 @@ impl LightcraftApp {
|
||||
panels::library_problem::show(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::activity::show(self, &ctx);
|
||||
export_task::poll_photos(self, &ctx);
|
||||
pick::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::left::album_drag_feedback(self, &ctx);
|
||||
|
||||
@@ -1913,8 +1913,8 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
/// Photo ▸ Edit In: render a copy of the active photo with its edits (stacked on the original)
|
||||
/// as Settings ▸ External Editing asks for that editor, then open it there. `p` may override the
|
||||
/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original)
|
||||
/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the
|
||||
/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first,
|
||||
/// so a missing editor leaves no copy behind.
|
||||
fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result<Value, String> {
|
||||
@@ -1939,14 +1939,27 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft:
|
||||
}
|
||||
}
|
||||
let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?;
|
||||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||||
if let Some(id) = r["id"].as_u64() {
|
||||
app.ui.external_edits.push(id);
|
||||
// every selected photo got a copy (#920); each opens in the editor
|
||||
let copies: Vec<(String, Option<u64>)> = r["copies"]
|
||||
.as_array()
|
||||
.map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect())
|
||||
.unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]);
|
||||
let mut failed = None;
|
||||
for (path, id) in &copies {
|
||||
if let Some(id) = id {
|
||||
app.ui.external_edits.push(*id);
|
||||
}
|
||||
if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) {
|
||||
failed = Some(e);
|
||||
}
|
||||
}
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
match app.services.open_with.as_mut().map(|f| f(&path, &editor)) {
|
||||
Some(Err(e)) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)),
|
||||
_ => app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)),
|
||||
match (failed, copies.as_slice()) {
|
||||
(Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)),
|
||||
(None, [(path, _)]) => {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name));
|
||||
}
|
||||
_ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -682,7 +682,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui,
|
||||
"Format",
|
||||
"exportFormat",
|
||||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")],
|
||||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")],
|
||||
&mut opts.format,
|
||||
);
|
||||
if !opts.format.is_rendered() {
|
||||
@@ -704,8 +704,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd);
|
||||
opts.bit_depth = Some(bd);
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png) {
|
||||
let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit and PNG as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float";
|
||||
if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) {
|
||||
let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float";
|
||||
ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip);
|
||||
if opts.hdr && !opts.hdr_output() {
|
||||
ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim));
|
||||
@@ -717,6 +717,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
} else if opts.format == F::Png && opts.hdr {
|
||||
ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim))
|
||||
.on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).");
|
||||
} else if opts.format == F::Jxl && opts.hdr {
|
||||
ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR JPEG XL)").color(t.text_dim));
|
||||
} else if opts.format == F::Avif {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim));
|
||||
} else {
|
||||
@@ -734,13 +736,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
],
|
||||
&mut opts.color_space,
|
||||
);
|
||||
if opts.effective_space() != opts.color_space {
|
||||
let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them.";
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Avif) {
|
||||
if opts.format == F::Jxl {
|
||||
let tip = "Mathematically lossless: every pixel is kept exactly (larger files)";
|
||||
ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip));
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) {
|
||||
let mut q = opts.quality as f64;
|
||||
if num(ui, &QUALITY, &mut q) {
|
||||
opts.quality = q as u8;
|
||||
}
|
||||
}
|
||||
if opts.format == F::Jxl {
|
||||
use lightcraft_engine::export::JxlEffort as E;
|
||||
choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort);
|
||||
let tip = "Shows a coarse version first and refines it as the file loads";
|
||||
ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip));
|
||||
}
|
||||
if opts.format == F::Jpeg {
|
||||
let mut k = *limit_kb as f64;
|
||||
if num(ui, &LIMIT_KB, &mut k) {
|
||||
@@ -905,6 +921,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
&[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")],
|
||||
&mut opts.conflict,
|
||||
);
|
||||
// After export: Apple Photos imports the files written (macOS only)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos"));
|
||||
crate::widgets::register(ui.ctx(), "exportAddToPhotos", r.rect);
|
||||
if opts.add_to_photos {
|
||||
field(ui, "Album", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY))
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos."))
|
||||
.size(11.0)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
}
|
||||
// nowhere to add them: a setting carried over from a Mac is dropped, not refused
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
opts.add_to_photos = false;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
field(ui, "Save preset", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
|
||||
@@ -524,11 +524,6 @@ fn section(
|
||||
fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0), Sense::click());
|
||||
register(ui.ctx(), "histogram", r);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, t.button);
|
||||
let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0));
|
||||
// the loupe render of this photo, else its thumbnail (grid view before the loupe has rendered it)
|
||||
let hist = app
|
||||
.renderer
|
||||
@@ -537,6 +532,39 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
.filter(|t| t.photo == id)
|
||||
.and_then(|t| t.histogram.clone())
|
||||
.or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone()));
|
||||
// an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label
|
||||
// row under the plot
|
||||
let hdr = hist.as_ref().and_then(|h| h.hdr);
|
||||
let legend = if hdr.is_some() { 26.0 } else { 0.0 };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click());
|
||||
register(ui.ctx(), "histogram", r);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, t.button);
|
||||
let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend));
|
||||
if let Some(b) = hdr {
|
||||
// white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR
|
||||
// range on) in its colours
|
||||
let x0 = plot.left() + plot.width() * b.from as f32 / 255.0;
|
||||
let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c);
|
||||
let white = Color32::from_rgb(228, 228, 230);
|
||||
bar(plot.left(), x0 - 1.0, white);
|
||||
let stops = b.stops.round().max(1.0) as usize;
|
||||
let bands = lightcraft_pipeline::visualize::HDR_BANDS;
|
||||
let colours = app.ui.hdr_visualize;
|
||||
for k in 0..stops {
|
||||
let c = if colours { bands.get(k).or(bands.last()).copied().unwrap_or([255, 255, 255]) } else { [228, 228, 230] };
|
||||
let (a, z) = (x0 + (plot.right() - x0) * k as f32 / stops as f32, x0 + (plot.right() - x0) * (k + 1) as f32 / stops as f32);
|
||||
bar(a + 1.0, z - 1.0, Color32::from_rgb(c[0], c[1], c[2]));
|
||||
}
|
||||
let y = plot.bottom() + 18.0;
|
||||
p.text(pos2((plot.left() + x0) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("SDR"), t.font(12.5), t.text_label);
|
||||
p.text(pos2((x0 + plot.right()) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("HDR"), t.font(12.5), t.text_label);
|
||||
let info = Rect::from_center_size(pos2(plot.right() - 16.0, y), vec2(14.0, 14.0));
|
||||
paint(p, info, Icon::Info, t.text_label);
|
||||
ui.interact(info, egui::Id::new("hdrHistogramInfo"), Sense::hover()).on_hover_text(crate::i18n::tr(
|
||||
"Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.",
|
||||
));
|
||||
}
|
||||
if let Some(h) = hist {
|
||||
let smooth = |v: &[u32]| -> Vec<f32> {
|
||||
(0..256)
|
||||
@@ -546,6 +574,27 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops
|
||||
if let Some(b) = h.hdr {
|
||||
let x0 = plot.left() + plot.width() * b.from as f32 / 255.0;
|
||||
let stops = b.stops.round().max(1.0) as usize;
|
||||
for k in 1..stops {
|
||||
let x = x0 + (plot.right() - x0) * k as f32 / stops as f32;
|
||||
let mut y = plot.top();
|
||||
while y < plot.bottom() {
|
||||
p.line_segment([pos2(x, y), pos2(x, (y + 5.0).min(plot.bottom()))], Stroke::new(1.0, Color32::from_gray(150)));
|
||||
y += 9.0;
|
||||
}
|
||||
}
|
||||
p.line_segment([pos2(x0, plot.top()), pos2(x0, plot.bottom())], Stroke::new(1.5, Color32::from_gray(160)));
|
||||
// the photo's headroom limit on the fixed scale: marked, the range past it dimmed
|
||||
let limit = app.session.develop_of(id).map_or(b.stops, |d| d.hdr.max_ev as f32);
|
||||
if limit.is_finite() && limit >= 0.0 && limit < b.stops - 0.05 {
|
||||
let x = x0 + (plot.right() - x0) * (limit / b.stops).clamp(0.0, 1.0);
|
||||
p.rect_filled(Rect::from_min_max(pos2(x, plot.top()), plot.max), 0.0, Color32::from_black_alpha(70));
|
||||
p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60)));
|
||||
}
|
||||
}
|
||||
let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))];
|
||||
let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b));
|
||||
let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0));
|
||||
|
||||
@@ -444,8 +444,10 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
|
||||
}
|
||||
|
||||
/// Request a grid/filmstrip thumbnail at `priority`. An unedited raw without a thumbnail texture
|
||||
/// first gets a stand-in (its cached thumbnail, else its embedded camera preview), and its real
|
||||
/// render then follows in the background.
|
||||
/// gets its embedded camera preview, and the render of the raw behind it follows in the background
|
||||
/// — except where that render would only redraw what the embedded JPEG already shows (see
|
||||
/// [`lightcraft_engine::media::Session::embedded_thumb_is_final`]), which for a folder of raws on a
|
||||
/// network share is the difference between reading every file whole and reading a preview each.
|
||||
pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) {
|
||||
let bucket = lightcraft_engine::media::thumb_bucket(size);
|
||||
if let Some(photo) = app.session.catalog.photo(id)
|
||||
@@ -453,19 +455,35 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The camera's embedded preview is this photo's thumbnail (unedited raw): once its quick render
|
||||
// has arrived there is nothing left to ask for. Without this the grid would rebuild the render
|
||||
// job — and its cache key — on every frame, which is what the embedded-thumbnail record settles.
|
||||
if let Some(photo) = app.session.catalog.photo(id)
|
||||
&& (app.renderer.thumb_quick_is_final(photo, bucket, priority) || app.renderer.thumb_quick_pending(photo, bucket))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(job) = app.session.thumb_job(id, size) else { return };
|
||||
#[cfg(test)]
|
||||
{
|
||||
app.renderer.thumb_jobs_built += 1;
|
||||
}
|
||||
let quick = if app.renderer.textures.contains_key(&Slot::Thumb(id)) { None } else { app.session.quick_thumb_job(&job) };
|
||||
// A cached render is a stand-in for whatever comes next; an embedded camera preview of an
|
||||
// unedited raw is the picture itself, so no sensor decode is scheduled behind it.
|
||||
let embedded_is_final =
|
||||
quick.is_some() && app.session.catalog.photo(id).is_some_and(|p| skips_the_real_render(app.session.embedded_thumb_is_final(p), true));
|
||||
if let Some(photo) = app.session.catalog.photo(id) {
|
||||
app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some());
|
||||
if embedded_is_final {
|
||||
app.renderer.remember_embedded_thumb(photo, bucket, job.key);
|
||||
} else {
|
||||
app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some());
|
||||
}
|
||||
}
|
||||
match quick {
|
||||
Some(q) => {
|
||||
app.renderer.request_quick(Slot::ThumbQuick(id), q, priority + 1);
|
||||
if !app.renderer.is_pending(Slot::ThumbQuick(id)) {
|
||||
if !embedded_is_final && !app.renderer.is_pending(Slot::ThumbQuick(id)) {
|
||||
app.renderer.request(Slot::Thumb(id), job, BACKGROUND_THUMB_PRIORITY);
|
||||
}
|
||||
}
|
||||
@@ -473,6 +491,19 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a photo's grid thumbnail is already the picture it should show, so the render of the raw
|
||||
/// behind it is skipped. `embedded_is_final` is the engine's answer
|
||||
/// ([`lightcraft_engine::media::Session::embedded_thumb_is_final`]: the photo is an unedited raw
|
||||
/// whose camera JPEG the viewer is showing) and `from_embedded` says the stand-in actually came from
|
||||
/// the embedded preview rather than a cached render of the file.
|
||||
///
|
||||
/// Skipping matters where the file is expensive to read: decoding a 56 MB Fuji RAF to draw a 512 px
|
||||
/// thumbnail — which the 4 416 × 2 944 embedded JPEG already covers — cost 45 s a photo over a
|
||||
/// 1.2 MB/s share, and the queue it built delayed the render the user was waiting for.
|
||||
pub(crate) fn skips_the_real_render(embedded_is_final: bool, from_embedded: bool) -> bool {
|
||||
embedded_is_final && from_embedded
|
||||
}
|
||||
|
||||
/// Rendered thumbnails replacing embedded previews: after everything on screen.
|
||||
pub const BACKGROUND_THUMB_PRIORITY: u32 = 3;
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ pub struct Renderer {
|
||||
/// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones.
|
||||
/// (photo, size bucket, job key, whether the request went through an embedded stand-in)
|
||||
thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool)>,
|
||||
/// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it).
|
||||
embedded_thumbs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>,
|
||||
pool: JobPool<Slot, RenderResult>,
|
||||
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
|
||||
offload: Option<Box<dyn RenderOffload>>,
|
||||
@@ -254,6 +256,7 @@ impl Renderer {
|
||||
preview_generation: None,
|
||||
pool: JobPool::new(threads),
|
||||
thumb_inputs: HashMap::new(),
|
||||
embedded_thumbs: HashMap::new(),
|
||||
offload: None,
|
||||
queue: Vec::new(),
|
||||
seq: 0,
|
||||
@@ -298,6 +301,30 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Remember the inputs of the thumbnail just requested (`quick`: through a stand-in).
|
||||
/// Whether the camera's embedded preview *is* this photo's thumbnail: its quick render arrived
|
||||
/// (so `Slot::ThumbQuick` holds the picture) and no edit has moved the photo on to a settings
|
||||
/// hash of its own. The grid stops here instead of decoding the raw behind a thumbnail the
|
||||
/// embedded JPEG already covers — see `grid::skips_the_real_render`.
|
||||
pub fn thumb_quick_is_final(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
|
||||
let Some((old, size, key)) = self.embedded_thumbs.get(&photo.id) else { return false };
|
||||
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority)
|
||||
}
|
||||
|
||||
/// Whether this photo's embedded-preview thumbnail is being fetched right now: the grid has
|
||||
/// asked, and neither the picture nor a render behind it is owed.
|
||||
pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize) -> bool {
|
||||
let Some((old, size, _, quick)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && *quick && self.pending.contains_key(&Slot::ThumbQuick(photo.id))
|
||||
}
|
||||
|
||||
/// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's
|
||||
/// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`]
|
||||
/// compares against).
|
||||
pub fn remember_embedded_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64) {
|
||||
self.remember_thumb(photo, bucket, key, true);
|
||||
self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key));
|
||||
}
|
||||
|
||||
pub fn remember_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64, quick: bool) {
|
||||
if self.thumb_inputs.len() >= 1024 && !self.thumb_inputs.contains_key(&photo.id) {
|
||||
self.thumb_inputs.retain(|_, (p, _, _, _)| p.strong_count() > 0);
|
||||
@@ -1032,8 +1059,9 @@ mod thumbnail_tests {
|
||||
|
||||
#[test]
|
||||
fn raw_quick_path_frames_build_no_jobs() {
|
||||
// an unedited raw: the grid shows its embedded preview first (held here until released),
|
||||
// then queues the real thumbnail behind everything on screen
|
||||
// an unedited raw: the grid shows its embedded preview (held here until released) and stops
|
||||
// there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the
|
||||
// whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share)
|
||||
let gate = Arc::new(std::sync::Mutex::new(()));
|
||||
let held = gate.lock().unwrap();
|
||||
let mut s = Session::new();
|
||||
@@ -1061,14 +1089,30 @@ mod thumbnail_tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
}
|
||||
// the stand-in is shown: one more job queues the real thumbnail at background priority,
|
||||
// and the grid asking at its own (higher) priority every frame does not rebuild it
|
||||
// the embedded preview is the thumbnail: no real render is queued behind it, and the grid
|
||||
// asking every frame neither rebuilds the job nor re-requests the preview
|
||||
for _ in 0..180 {
|
||||
request(&mut app);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 2);
|
||||
assert_eq!(app.renderer.queue.len(), 1);
|
||||
assert_eq!(app.renderer.pending.get(&Slot::Thumb(PhotoId(1))).map(|p| p.1), Some(crate::panels::grid::BACKGROUND_THUMB_PRIORITY));
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail");
|
||||
assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it");
|
||||
assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1))));
|
||||
|
||||
// an edit takes the embedded preview out of the picture: the real thumbnail is rendered then
|
||||
let mut edited = (*photo()).clone();
|
||||
edited.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
edited.develop = Arc::new({
|
||||
let mut d = edited.camera_defaults();
|
||||
d.light.exposure = 0.5;
|
||||
d
|
||||
});
|
||||
let (mut app, _, _ctx) = fixture_with(Session::new(), edited);
|
||||
app.renderer.set_offload(Box::new(Blocked));
|
||||
for _ in 0..180 {
|
||||
request(&mut app);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 1);
|
||||
assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,10 +93,10 @@ fn export_dialog_is_unchanged_in_a_tall_window() {
|
||||
let h = export_dialog([1600.0, 1000.0]);
|
||||
assert_fits(&h, "Export at 1600×1000");
|
||||
let dialog = rect(&h, "dialog:window");
|
||||
// (846 tall as before #781: the whole dialog, nothing scrolled away; 546 wide since the
|
||||
// watermark's nine positions, #833)
|
||||
// (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the
|
||||
// format row gained JPEG XL, #780)
|
||||
assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}");
|
||||
assert!((dialog.width() - 546.0).abs() <= 1.0, "{dialog:?}");
|
||||
assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}");
|
||||
}
|
||||
|
||||
// Scenario: other long dialogs fit a short window too, with their buttons on screen
|
||||
|
||||
@@ -155,3 +155,20 @@ fn unloaded_thumbnails_show_their_selection() {
|
||||
}
|
||||
assert!(checked >= 3, "checked {checked}");
|
||||
}
|
||||
|
||||
/// A folder of unedited raws on a slow share: the grid must not decode every raw to draw a 512 px
|
||||
/// thumbnail the embedded camera JPEG already covers. Measured on a 56 MB Fuji RAF over a 1.2 MB/s
|
||||
/// share, that decode is 45 s a photo, and 149 of them queued in front of the render the user asked
|
||||
/// for. The embedded JPEG stays a *stand-in* — replaced by a real render — whenever it is not the
|
||||
/// picture the photo should show.
|
||||
#[test]
|
||||
fn an_unedited_raws_thumbnail_skips_the_sensor_decode() {
|
||||
use crate::panels::grid::skips_the_real_render;
|
||||
// unedited raw, embedded camera JPEG on screen: nothing behind it to render
|
||||
assert!(skips_the_real_render(true, true));
|
||||
// a cached render of the file was the stand-in, even for an unedited raw: the real one follows
|
||||
assert!(!skips_the_real_render(true, false));
|
||||
// edited photo, or a raw with no embedded JPEG: render it
|
||||
assert!(!skips_the_real_render(false, true));
|
||||
assert!(!skips_the_real_render(false, false));
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# Add to Apple Photos (macOS)
|
||||
|
||||
An export can hand the files it wrote to Apple Photos, optionally into an album (issue #236, parity row
|
||||
`LR-EXP-PHOTOS`). macOS only: on other platforms the option is hidden in the Export dialog and refused by
|
||||
`app.export`. The web build doesn't include it.
|
||||
|
||||
> **Tested end to end** on macOS 26.6.2 with Photos 11.0, using the ad-hoc-signed packaged app and a new, empty
|
||||
> Photos library: the [manual test](#manual-test) below, except the two `lightcraft-cli` steps (11 and 12). Please
|
||||
> run it again after changing the script or the packaging.
|
||||
|
||||
## Using it
|
||||
|
||||
- **Export dialog:** tick **Add to Apple Photos** under *If file exists* and, if you like, type an **Album**
|
||||
name (empty = no album; see [Which album](#which-album)). The files are exported as usual, into the folder
|
||||
you chose, and then added to Photos. The toast says how many Photos took, or why it took none.
|
||||
- **Export with Previous** and saved export presets repeat it (the setting is part of the export options).
|
||||
- **Agents and scripts:** `app.export {…, addToPhotos: true, photosAlbum?: "Name"}` (desktop app, MCP `export`
|
||||
tool, `lightcraft-cli run`); `lightcraft-cli render in.dng -o out.jpg --opt addToPhotos=true --opt
|
||||
photosAlbum=Name`. The export's result carries `applePhotos` (below). The files are written either way: a
|
||||
failed Photos step doesn't fail `app.export` (its `applePhotos` has the `error`), while `lightcraft-cli render`
|
||||
says the file was written and exits with the error.
|
||||
- **Files already on disk:** `export.addToPhotos {paths: [absolute paths], album?, wait?}`.
|
||||
- **How an import went:** `export.photosImports` lists this session's imports, oldest first;
|
||||
`export.photosImports {job}` gives one.
|
||||
|
||||
### Waiting, or not
|
||||
|
||||
An import can take minutes, and the first one waits for the user to answer macOS's permission prompt. Each
|
||||
import is a job: `{job, running, requested, album}`, plus, once Photos is done, `{imported, ids, warning?}`,
|
||||
`{error}` or `{skipped}` (`exporting: true` while an export that will add its files is still writing them).
|
||||
|
||||
While Photos works, the activity stack ([background-tasks.md](background-tasks.md)) shows it. An export adding its
|
||||
files keeps its own row, which reads "Adding to Apple Photos…" and loses its ✕, since Photos can't be stopped once
|
||||
asked. An import of its own (`export.addToPhotos`, or the Photos step left running after `app.export` without
|
||||
`background`) gets a row of kind `applePhotos`, "Adding to Apple Photos", with the album as its detail, no ✕ and no
|
||||
count, as Photos reports none until it is done.
|
||||
|
||||
- In the **desktop app** nothing waits for Photos on the window's thread. The Export dialog's background export
|
||||
adds the files on its worker and reports them in its toast. `app.export` without `background` and
|
||||
`export.addToPhotos` answer at once with the running job (`running: true`); the app shows a toast when Photos
|
||||
is done, and `export.photosImports {job}` gives the outcome. (`export.addToPhotos {wait: true}` is refused there:
|
||||
the window's thread never waits for Photos.)
|
||||
- **Without a window** (the MCP server, `lightcraft-cli`, the headless snapshot) everything waits for Photos by
|
||||
default: `app.export` and `export.addToPhotos` return the finished job, so a one-shot command never ends in
|
||||
the middle of an import. `export.addToPhotos {wait: false}` returns the running job instead (for a
|
||||
long-running MCP server that polls `export.photosImports`); a process that is about to end (the CLI after
|
||||
its last command, the MCP server at end of input, a snapshot script, also when they end with an error)
|
||||
first waits for such an import to finish. The wait follows the import: while an export is still writing the
|
||||
files it will add, up to an hour more of writing; once Photos is asked, the import's own time limit (see the
|
||||
table below) and half a minute; never more than three hours and one minute in all.
|
||||
- **One import at a time.** An export that will add to Photos reserves the import before it writes anything
|
||||
and holds it until it hands its files over (or is cancelled, fails or writes nothing). While an import is
|
||||
reserved or running, another (`export.addToPhotos`, or an export asking for Photos, refused before it writes
|
||||
anything) is refused with the number of the one in the way, so a retry never sends the same files twice and
|
||||
an export is never turned away after writing its files. When LightCraft stops waiting (see the table below),
|
||||
Photos may still be importing: look in Photos before trying again.
|
||||
|
||||
### Which album
|
||||
|
||||
The album is looked for by its exact name (case included) among the albums at the **top level** of the
|
||||
Photos library. Albums inside folders are never used: when the only album of that name is in a folder, a new
|
||||
top-level album is made. When none is there, a new top-level album is made. When **more than one** top-level
|
||||
album has the name, nothing is imported and LightCraft says so: rename one of them in Photos, or use another
|
||||
name. (Photos' own list of albums includes those in folders, in no particular order, so the script checks
|
||||
each one's parent folder.)
|
||||
|
||||
Photos either copies the files into its library or references them where they are, as set in Photos ▸
|
||||
Settings ▸ General ▸ *Copy items to the Photos library*. When it references them, keep the exported files.
|
||||
|
||||
## How it works
|
||||
|
||||
LightCraft runs `/usr/bin/osascript` with a constant AppleScript (`SCRIPT` in
|
||||
`crates/engine/src/apple_photos.rs`) that asks Photos, through its scripting interface, to import the files:
|
||||
`import … into album … skip check duplicates false`. The album is found or made first ([Which
|
||||
album](#which-album)). The script returns the new media items' ids. Files go to Photos 200 at a time (each path is an osascript argument, and
|
||||
macOS limits a program's arguments to 1 MiB), one run after another within the import's time limit; the first
|
||||
run makes the album when it is missing.
|
||||
|
||||
This is Apple Events to Photos' own import command. It is not the OS-level synthetic input that AGENTS.md
|
||||
forbids: nothing is typed or clicked, and no other window is touched.
|
||||
|
||||
The album name and the file paths are never written into the script. They follow it as separate arguments
|
||||
(`on run argv`), each passed to osascript unchanged, so quotes, backslashes, line breaks or a leading `-` in a
|
||||
name or path can't change what the script does. A constant first argument keeps osascript from reading a
|
||||
name that starts with `-` as one of its own options.
|
||||
|
||||
The first time, macOS asks whether LightCraft may control Photos. The answer is kept in System Settings ▸
|
||||
Privacy & Security ▸ Automation. macOS asks about the app that started LightCraft's work: the LightCraft app
|
||||
when it was opened from the Finder or with `open`, but the **terminal** for `lightcraft-cli` and for a
|
||||
LightCraft started from a terminal (`cargo run`).
|
||||
|
||||
**Packaged app.** The release app is signed with the hardened runtime. For it to be allowed to ask, it carries
|
||||
the `com.apple.security.automation.apple-events` entitlement (`packaging/macos/entitlements.plist`, also used
|
||||
for `lightcraft-cli`), and its `Info.plist` has the `NSAppleEventsUsageDescription` text macOS shows in the
|
||||
prompt (`packaging/macos/Info.plist.in`). Without them macOS refuses the events (`-1743`) without asking.
|
||||
`cargo run` doesn't test this configuration; the manual test below uses the packaged app.
|
||||
|
||||
**Who can add what.** `export.addToPhotos {paths}` adds any file LightCraft can read, given its absolute path,
|
||||
not only files it exported. So any client of the control port or the MCP server can put any readable image or
|
||||
video on the Mac into the user's Photos library, and from there into iCloud Photos when that is on. macOS asks
|
||||
for the Automation permission once per app, not per import, so after the first Allow nothing asks again. The
|
||||
control port listens on 127.0.0.1 only and has no authentication ([control-protocol.md](control-protocol.md)),
|
||||
so any program on this Mac that can connect to it can do this, including programs of other users logged in to
|
||||
the Mac. The MCP server talks only to the program that started it (stdio). Treat giving an agent the MCP server
|
||||
or the control port as giving it this too, and turn the control port on only when you need it.
|
||||
|
||||
## When it doesn't work
|
||||
|
||||
| What you see | Why, and what to do |
|
||||
|---|---|
|
||||
| "LightCraft isn't allowed to control Photos…" | Permission refused (`-1743`). Turn on Photos under LightCraft (or your terminal app) in System Settings ▸ Privacy & Security ▸ Automation, then try again. |
|
||||
| "Apple Photos couldn't be found…" | Photos isn't installed or couldn't be opened (`-600`, `-10810`, `-10814`, or no `/System/Applications/Photos.app`). |
|
||||
| "The import was cancelled in Photos." | Someone pressed Cancel in a Photos dialog (`-128`). |
|
||||
| "More than one album at the top level of Photos is named …" | Several top-level albums have that name; nothing was added. Rename one in Photos, or use another name. |
|
||||
| "Photos didn't finish adding the photos within N s…" / "didn't answer in time" | LightCraft waits ten minutes plus five seconds a file (at most two hours), then stops osascript. The ten minutes leave time to answer macOS's permission prompt and Photos' duplicates question. Photos may still be importing: look in Photos before trying again. |
|
||||
| "Apple Photos is still adding N file(s) from an earlier request (import K)…" / "An export is about to add its files to Apple Photos (import K)…" | One import at a time: wait for import K (`export.photosImports {job: K}`), then try again. |
|
||||
| "Photos added 2 of 3 files…" | A partial import: Photos skipped files it found to be duplicates (it may ask first) or couldn't read. |
|
||||
| "Apple Photos is only available on macOS" | The setting came from a Mac (a preset or Export with Previous); the Export dialog drops it on other platforms. |
|
||||
|
||||
## Manual test
|
||||
|
||||
Run this once on a Mac whose Photos library you are happy to add a few test images to (or switch Photos to a
|
||||
new empty library first: hold ⌥ while opening Photos ▸ Create New).
|
||||
|
||||
1. Package the app (ad-hoc signed unless `MACOS_SIGN_IDENTITY` is set): `packaging/macos/package.sh --arch
|
||||
aarch64` (or `x86_64` on an Intel Mac). Check its entitlement: `codesign -d --entitlements -
|
||||
target/macos-package/LightCraft.app` lists `com.apple.security.automation.apple-events`. With a Developer ID
|
||||
build, also run the notarized DMG from `dist/release/`.
|
||||
2. Open it from the Finder, or with `open target/macos-package/LightCraft.app --args --demo` (not by running
|
||||
the binary in a terminal, which makes the terminal the app macOS asks about).
|
||||
3. Select three photos, choose File ▸ Export…, set a folder (e.g. `~/Pictures/LightCraft Exports`), tick **Add
|
||||
to Apple Photos**, type the album `LightCraft test "1"` and press OK.
|
||||
4. macOS asks whether **LightCraft** may control Photos, showing the usage text from `Info.plist`: allow it.
|
||||
Expected: the toast reads "Exported 3 of 3 photos · 3 added to Apple Photos album “LightCraft test "1"”",
|
||||
and a top-level album of that name holds the three images.
|
||||
5. Export the same photos again with the same settings. Expected: Photos asks about duplicates or imports them
|
||||
again; the toast reports the count Photos returned (a partial import shows its warning).
|
||||
6. Albums: in Photos make a folder holding an album `Nested`, then export into `Nested`. Expected: a new
|
||||
top-level album `Nested` gets the images, the one in the folder doesn't. Then make a second top-level album
|
||||
`Nested` and export again. Expected: nothing is added, and the toast says more than one album has the name.
|
||||
7. Permission refused: in System Settings ▸ Privacy & Security ▸ Automation turn off Photos under LightCraft,
|
||||
export again. Expected: the files are written, and the toast says to allow LightCraft there. Turn it back
|
||||
on.
|
||||
8. No album: keep Add to Apple Photos ticked, clear the Album field and export. Expected: the images land in the
|
||||
library, in no album.
|
||||
9. Hostile names: album `-e`, then `a\b`, and an export folder whose name contains `"` and `'`; a name with a
|
||||
line break through the CLI: `lightcraft-cli run --demo app.export dir=/tmp/lc-photos addToPhotos=true
|
||||
"photosAlbum=$(printf 'two\nlines')"`. Expected: albums named exactly so; nothing else happens.
|
||||
10. The window stays responsive: start the app with `open target/macos-package/LightCraft.app --args --demo
|
||||
--control 18999`, then send `{"method": "engine.execute", "params": {"command": "export.addToPhotos",
|
||||
"params": {"paths": ["/tmp/lc-photos/<file>.jpg"]}}}` to port 18999 (`docs/control-protocol.md`).
|
||||
Expected: an immediate `{"job": …, "running": true}`; the app keeps responding; a toast when Photos is done;
|
||||
`export.photosImports` then shows the outcome.
|
||||
11. Agents: `lightcraft-cli run --demo app.export dir=/tmp/lc-photos addToPhotos=true photosAlbum=CLI` (allow
|
||||
the terminal under Automation when asked). Expected: `applePhotos.imported` in the printed result.
|
||||
12. Files on disk: `lightcraft-cli run export.addToPhotos 'paths=["/tmp/lc-photos/<file>.jpg"]' album=CLI`.
|
||||
Expected: the command waits for Photos and prints `{"job": …, "running": false, "imported": 1, …}`.
|
||||
|
||||
Report the toast or JSON, and the macOS and Photos versions.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Albums inside folders are never used ([Which album](#which-album)).
|
||||
- Photos' duplicate check stays on, so Photos may ask about duplicates; what it skips shows as a partial import.
|
||||
- Cancelling a running export cancels the Photos step too; a Photos import already started can't be stopped
|
||||
from LightCraft.
|
||||
- One import runs (or is reserved by an export) at a time; a session remembers its last 32.
|
||||
- Quitting the desktop app while Photos is importing doesn't wait for it: Photos may still finish the import,
|
||||
but LightCraft won't show the outcome.
|
||||
@@ -21,14 +21,14 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/
|
||||
| PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` |
|
||||
| HEIC / HEIF | ✅ | ⬜ (Classic) / HDR only | ✅ in every release build (`crates/heif`, heic-rs) | ⬜ | `heif` feature on by packaging scripts |
|
||||
| AVIF | ✅ | ✅ (HDR) | ⬜ recognised, not decoded (no permissive pure-Rust AV1 decoder) | ✅ 8/10-bit, PQ HDR | |
|
||||
| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ⬜ | |
|
||||
| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` |
|
||||
| WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target |
|
||||
| GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target |
|
||||
| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed or LJ92; ⬜ lossy DNG, embedded original | `crates/raw/src/dngwrite.rs` |
|
||||
| Render to DNG (15.5) | — | ✅ | — | ⬜ | new in Classic 15.5 |
|
||||
| Original + sidecar | — | ✅ | — | ✅ | |
|
||||
| HDR gain map JPEG (ISO 21496-1) | ✅ | ✅ | ✅ (gain map read) | ✅ | `crates/codecs/src/gainmap.rs` |
|
||||
| PQ / HLG HDR | ✅ | ✅ AVIF, JXL, TIFF | — | 🟡 PQ AVIF, float TIFF; no HLG | |
|
||||
| PQ / HLG HDR | ✅ | ✅ AVIF, JXL, TIFF | — | 🟡 PQ AVIF, PQ JPEG XL, float TIFF; no HLG | |
|
||||
| Contact sheet / print PDF | — | ✅ (Print module: PDF / JPEG / printer) | — | 🟡 paginated contact-sheet PDF (A4 / Letter, 150 dpi, sRGB) | `crates/engine/src/contact_sheet.rs` |
|
||||
| Video (MP4, MOV, AVCHD…) | ✅ playback, trim, edit | ✅ export | 🟡 catalogued (kind, duration) | ⬜ | see video gap |
|
||||
|
||||
@@ -63,17 +63,17 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/
|
||||
|
||||
| Part | Weight | Ready for real work | Kind |
|
||||
|---|---:|---:|---|
|
||||
| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, JXL / PSD write, lossy DNG) |
|
||||
| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, PSD write, lossy DNG) |
|
||||
| Sidecars, metadata, presets | 25% | ~85% | estimated |
|
||||
| Lightroom catalog migration | 25% | ~50% | estimated (one-way, approximate rendering of unsupported settings) |
|
||||
| HDR, print, video output | 15% | ~35% | estimated |
|
||||
| **Non-raw file formats** | 100% | **~70%** | estimated; raw is in [raw-parity.md](raw-parity.md) (~50%) |
|
||||
|
||||
Remaining: **30–55 h** (AVIF decode 8–15 h if a permissive decoder is written or found; JXL encode
|
||||
4–8 h; PSD write 4–8 h; lossy DNG and Render to DNG 4–8 h; HLG 3–5 h; catalog import depth 8–12 h).
|
||||
Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is written or found; PSD write 4–8 h; lossy DNG and Render to DNG 4–8 h; HLG 3–5 h; catalog import depth 8–12 h).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created from the code and the installed Lightroom's document types |
|
||||
| 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR |
|
||||
+2
-2
@@ -39,7 +39,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
|
||||
| 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) |
|
||||
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
|
||||
| 25 | **File writes**: JXL encode, PSD export, lossy DNG, Render to DNG; AVIF import | file-format-parity.md | interchange edge cases | 25–45 | [file-format-parity](file-format-parity.md) |
|
||||
| 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) |
|
||||
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) |
|
||||
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
|
||||
@@ -60,7 +60,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
## Closed recently (kept for calibration)
|
||||
|
||||
- 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float
|
||||
TIFF export (#646), activity stack (#345).
|
||||
TIFF export (#646), activity stack (#345), JPEG XL export (#247).
|
||||
- 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563),
|
||||
Ukrainian UI (#564), Sony ILCE-7CR profile.
|
||||
- 2026-10-08: CR3 CRX lossless and C-RAW (#279), Fujifilm compressed RAF (#282), AI RAW denoise (#384),
|
||||
|
||||
+19
-1
@@ -168,7 +168,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
|
||||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
|
||||
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
|
||||
|
||||
Tools taking `id` make that photo active first; without it they act on the active photo.
|
||||
|
||||
@@ -255,6 +255,7 @@ lightcraft-cli run --demo --script steps.jsonl --keep-going
|
||||
lightcraft-cli render in.dng -o out.tif --opt colorSpace=displayP3 --opt bitDepth=16 --opt percent=50
|
||||
lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.5 --set light.contrast=20 --size 2048
|
||||
lightcraft-cli render in.jpg -o out.png --settings look.json --preset <presetId>
|
||||
lightcraft-cli render in.dng -o out.jpg --opt addToPhotos=true --opt photosAlbum=Trip # macOS: then into Apple Photos
|
||||
lightcraft-cli commands [--json] # the command registry
|
||||
lightcraft-cli controls [--json] # develop control ids and ranges
|
||||
lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (docs/camera-preview-colour.md)
|
||||
@@ -268,6 +269,23 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d
|
||||
- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`;
|
||||
`commands`.
|
||||
|
||||
## Add to Apple Photos (macOS)
|
||||
|
||||
`app.export` (MCP `export` tool, `lightcraft-cli run`, the desktop app) takes `addToPhotos: true` and
|
||||
`photosAlbum` to hand the files it wrote to Apple Photos, into that album (a top-level album of exactly that
|
||||
name, made when missing). The result's `applePhotos` is the import job: `{job, running, requested, album}`
|
||||
plus `{imported, ids, warning?}`, `{error}` or `{skipped}` once Photos is done; the files are written either
|
||||
way. Headless (this server, `lightcraft-cli`) waits for Photos by default; the desktop app (connect mode)
|
||||
answers with the running job and `export.photosImports {job}` gives the outcome. `export.addToPhotos {paths,
|
||||
album?, wait?}` does the same for files already on disk (`wait` defaults to true headless, false in the desktop
|
||||
app, which refuses `wait: true`; with `wait: false` headless, the server waits for the import before it exits at end of input). One import
|
||||
runs at a time, and an export adding to Photos reserves it before it writes: another is refused, naming the
|
||||
job in the way. On other platforms `addToPhotos: true` is refused before anything is written, and
|
||||
`export.addToPhotos` is disabled with the reason. The first time, macOS asks the user to allow LightCraft (or
|
||||
the terminal running `lightcraft-cli`) to control Photos. After that, any client can add any file LightCraft can
|
||||
read to the user's Photos library (and so to iCloud Photos when that is on), not only exported ones: see [Who
|
||||
can add what](apple-photos.md#how-it-works).
|
||||
|
||||
## Export progress and cancellation
|
||||
|
||||
Headless direct `export`, `command_run` / `run_command` with `app.export`, and `cmd_app_export`
|
||||
|
||||
+13
-12
@@ -26,7 +26,7 @@ cited here still exists. Formerly docs/parity.md.
|
||||
<!-- parity:summary -->
|
||||
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) |
|
||||
| A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) |
|
||||
| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) |
|
||||
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
@@ -44,7 +44,7 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
|
||||
| Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — |
|
||||
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
|
||||
| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| S. Export (EXP) | 15 | 3 | 0 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
|
||||
@@ -80,8 +80,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 Line truncated
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered)Line truncated
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). |
|
||||
| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
|
||||
@@ -137,7 +138,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends |
|
||||
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) |
|
||||
| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | |
|
||||
| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram |
|
||||
| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram |
|
||||
| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | |
|
||||
|
||||
## D. Search & filter (FILT)
|
||||
@@ -364,8 +365,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) |
|
||||
| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands |
|
||||
| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it |
|
||||
| LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops |
|
||||
| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) |
|
||||
| LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) |
|
||||
| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) |
|
||||
|
||||
## R. Video (VID)
|
||||
|
||||
@@ -383,13 +384,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
|
||||
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported |
|
||||
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build |
|
||||
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags |
|
||||
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | |
|
||||
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
|
||||
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
|
||||
| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | |
|
||||
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) |
|
||||
| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, PQ PNG, float TIFF; no HDR presets yet |
|
||||
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile |
|
||||
| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet |
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` |
|
||||
@@ -398,8 +399,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output |
|
||||
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy |
|
||||
| LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | |
|
||||
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet |
|
||||
| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered |
|
||||
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet |
|
||||
|
||||
## T. Share (SHARE)
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Reading a raw's preview without reading the raw
|
||||
|
||||
A grid thumbnail and a loupe preview of an unedited raw both show the camera's embedded JPEG, not a
|
||||
decode of the sensor data. That JPEG lives near the *start* of the file, but the rest of the file is
|
||||
the sensor data — for a 24 MP Fuji RAF, **50 of 56 MB**. Until this change the preview loader called
|
||||
`std::fs::read` on the whole file and threw the sensor data away, so every preview paid for bytes
|
||||
nobody looked at.
|
||||
|
||||
That is free on a local SSD and expensive on a network share, which is where photographers keep
|
||||
libraries. Measured on one real file (`DSCF9492.RAF`, 56.5 MB) over a GVFS/SMB share on Wi-Fi:
|
||||
|
||||
| | read | time |
|
||||
|---|---|---|
|
||||
| whole file | 59 277 632 B (100 %) | ~10 s |
|
||||
| header + preview | **5 857 257 B (9.9 %)** | **0.96 s** |
|
||||
|
||||
## How it works
|
||||
|
||||
1. `read_prefix` reads the first `PREVIEW_PREFIX` (4 KiB); every format whose preview is locatable
|
||||
from the header keeps its pointer well inside that.
|
||||
2. For Fuji RAF the header's two big-endian `u32`s at **84** (offset) and **88** (length) are the
|
||||
same fields `lightcraft_raw`'s RAF reader uses. When they declare more than the prefix holds, the
|
||||
prefix is re-read once at exactly that extent: `lightcraft_raw::bounded_preview` reports the range
|
||||
its buffer covers (`slice` clamps to `b.len()`), so a short prefix yields a *plausible but
|
||||
truncated* range — a real 4 KiB prefix reports 3 948 bytes where the file holds 5.6 MB.
|
||||
3. `embedded_preview_from_range` checks the window really starts with a JPEG SOI and trims it at the
|
||||
last EOI marker, exactly as `embedded_preview` trims the whole-file answer.
|
||||
4. When the prefix already covers the range — the usual case, since it was grown to it — the window
|
||||
is a slice of the prefix. Reading it again would double a 5.6 MB preview.
|
||||
|
||||
Formats whose preview pointer is not in the header (CR3 scans boxes; a TIFF pointer tag can sit in
|
||||
an IFD the prefix cuts short; CRW/MRW/X3F are found by scanning) keep the whole-file path, so
|
||||
nothing regresses: `preview_window` returns `None` and the loader reads the file as before. A pointer
|
||||
that lies, a short read or a file that changes underneath us ends in the same fallback — the user's
|
||||
preview is never lost to a damaged header.
|
||||
|
||||
`LIGHTCRAFT_PROFILE=1` prints what each preview cost:
|
||||
|
||||
```
|
||||
[profile] preview …/DSCF9492.RAF: read 5857257 of 59277632 bytes (header 5857257, window 5857109)
|
||||
```
|
||||
|
||||
## The grid thumbnail is the embedded JPEG too
|
||||
|
||||
A 512 px grid thumbnail of an unedited raw now keeps the camera's own JPEG instead of rendering the
|
||||
sensor behind it. The grid showed that JPEG first in any case and then queued a decode of the whole
|
||||
raw to replace it — for a folder of 149 Fuji RAFs that is 8.3 GB of traffic, 45 s a photo at
|
||||
1.2 MB/s, and a queue that delayed the render the user was actually waiting for (measured: the first
|
||||
loupe render of a cold photo finished 174 s after the click, most of it spent behind thumbnails).
|
||||
|
||||
- `Session::embedded_thumb_is_final(photo)` says whether the photo is an unedited raw whose camera
|
||||
JPEG the viewer is showing (the same "unedited raw" gate as the preview path: `has_import_look`).
|
||||
- `grid::skips_the_real_render(embedded_is_final, from_embedded)` is the decision, unit-tested:
|
||||
a **cached render** as the stand-in, an **edited photo** and a **raw with no embedded JPEG** all
|
||||
still render, because their thumbnail must show the pipeline's output rather than the camera's.
|
||||
- The thumbnail key still changes with the settings hash, so editing a photo re-renders its
|
||||
thumbnail through the ordinary background path.
|
||||
|
||||
Measured on the real folder: idle for a minute, the app read 12 previews (**26.9 MB**) and decoded
|
||||
**zero** raws for thumbnails, against 638 MB of whole-file reads for the same 12 photos before. The
|
||||
render queue stayed empty.
|
||||
|
||||
## Tests
|
||||
|
||||
- `crates/raw/src/preview.rs`: the header-only range matches `embedded_preview` byte for byte; a
|
||||
clamped prefix, a padded extent, a late pointer, a lying pointer and a non-JPEG window are all
|
||||
covered.
|
||||
- `crates/engine/src/files.rs`: on a synthetic RAF with a 200 KB tail, the quick path and the
|
||||
whole-file path produce identical pixels, and the read stays under half the file.
|
||||
- `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`): the same measurement on a **real**
|
||||
file, typically on a share:
|
||||
|
||||
```sh
|
||||
LC_PREVIEW_RAW="/mnt/nas/…/DSCF0001.RAF" \
|
||||
cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture
|
||||
```
|
||||
|
||||
No raw fixture is committed (media never is, see `AGENTS.md`).
|
||||
|
||||
A network share is still slower than a local disk — the preview itself is 5.6 MB — and the sensor
|
||||
data still has to come over the wire for the *developed* picture, which is a separate path
|
||||
(`fs_pair_loader`, the full decode). This change removes the preview's share of it, nothing more.
|
||||
@@ -168,6 +168,8 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
|
||||
- **Preset files** (import/export `.json`) aren't wired to browser pickers yet.
|
||||
- **Control channel / MCP.** These are desktop-only, because they need a TCP socket.
|
||||
- **AVIF export** is untested in the browser; it's the one encoder that may not be wasm-safe.
|
||||
- **JPEG XL export** is native only: the encoder (`jixel`) starts threads of its own, which wasm32 has not got; the
|
||||
web build reports "not available in this build". JPEG XL import works.
|
||||
|
||||
## Measuring
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
## 2026-10-11
|
||||
|
||||
### JPEG XL export
|
||||
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
|
||||
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a
|
||||
smaller file; Progressive files show a coarse version first. 8- or 16-bit, sRGB, Display P3 or Rec. 2020 (lossless
|
||||
files also Adobe RGB and ProPhoto), metadata as for JPEG. With HDR output, photos edited in HDR are written as
|
||||
16-bit Rec. 2020 PQ. Not in the web version yet.
|
||||
- Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in
|
||||
noticeably duller.
|
||||
|
||||
### Open Library knows a Lightroom catalog
|
||||
- Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /
|
||||
`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The
|
||||
@@ -32,6 +41,11 @@
|
||||
the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy.
|
||||
- Export can write PSD too (flat, 8- or 16-bit).
|
||||
|
||||
### Reliability
|
||||
- Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed
|
||||
in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade
|
||||
installs to Program Files.
|
||||
|
||||
## 2026-10-10
|
||||
|
||||
### Dialogs fit the window
|
||||
@@ -87,6 +101,15 @@
|
||||
Appearance lists the modes and themes. On Linux, Auto follows the desktop portal's colour scheme
|
||||
as it changes, without polling.
|
||||
|
||||
### Add to Apple Photos (macOS)
|
||||
- The Export dialog has **Add to Apple Photos**, with an optional album. The files are exported to your folder as
|
||||
usual and then added to Photos, into a top-level album of that name (made when it's missing). The toast says how
|
||||
many Photos added, or why it added none. Export with Previous and export presets repeat it.
|
||||
- The first time, macOS asks whether LightCraft may control Photos. Photos may ask about duplicates; files it skips
|
||||
show as a partial import.
|
||||
- Agents: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` for files already on disk, and
|
||||
`export.photosImports`. See [Add to Apple Photos](apple-photos.md).
|
||||
|
||||
### Activity stack
|
||||
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
|
||||
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
|
||||
|
||||
+7
-1
@@ -37,6 +37,10 @@ used — on import (it then also files a copied photo in its date folder) and by
|
||||
`xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
|
||||
`lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate).
|
||||
|
||||
When foreign `crs:` settings are only partly transferable, `library.import` includes per-photo `warnings` in its
|
||||
report. Warnings identify unsupported profile names, saved Upright geometry that will be recalculated, and other
|
||||
unmapped adjustments; they do not change the source packet or become catalog edits.
|
||||
|
||||
## Saving into an existing sidecar
|
||||
|
||||
A sidecar may already hold another application's data — e.g. its `crs:` develop settings and `xmpMM:History` — often
|
||||
@@ -136,11 +140,13 @@ application's renderer and is ignored: the photo keeps LightCraft's own process,
|
||||
| `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | |
|
||||
| `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 |
|
||||
| `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | |
|
||||
| `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided |
|
||||
| `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided; saved `UprightTransform_*` matrices are not imported, so photo import reports that Lightroom geometry was not preserved and the selected mode will be recalculated |
|
||||
| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop |
|
||||
|
||||
Values pass through our control specs, so anything outside our slider ranges gets clamped.
|
||||
|
||||
`crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for.
|
||||
|
||||
**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur,
|
||||
process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below.
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<!-- Shown by macOS when LightCraft first asks to control Photos (Export > Add to Apple Photos;
|
||||
needs the apple-events entitlement in entitlements.plist). -->
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>LightCraft asks Photos to import the photos you export when you choose Add to Apple Photos in the Export dialog.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © the LightCraft authors. MIT OR Apache-2.0.</string>
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- Hardened-runtime entitlements for Lightcraft. Deliberately empty: the app is pure Rust,
|
||||
<!-- Hardened-runtime entitlements for Lightcraft. Deliberately minimal: the app is pure Rust,
|
||||
loads no unsigned code, needs no JIT, and is not sandboxed (it opens files by path, from the
|
||||
command line and drag-and-drop). Add exceptions here only with a reason in a comment. -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Export > Add to Apple Photos (crates/engine/src/apple_photos.rs) runs /usr/bin/osascript to
|
||||
send Apple Events to Photos' import command. Under the hardened runtime macOS refuses those
|
||||
events (error -1743, without asking the user) unless the app has this entitlement; the
|
||||
prompt shows NSAppleEventsUsageDescription from Info.plist. -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -210,7 +210,10 @@ CLI_DIR="$WORK/lightcraft-cli-$VERSION-macos-$ARCH"
|
||||
mkdir -p "$CLI_DIR"
|
||||
cp "$WORK/bin/lightcraft-cli" "$CLI_DIR/"
|
||||
copy_docs "$CLI_DIR"
|
||||
sign --options runtime "$CLI_DIR/lightcraft-cli"
|
||||
# Same entitlements as the app: `--opt addToPhotos=true` / `app.export {addToPhotos}` send Apple
|
||||
# Events to Photos through osascript too (macOS asks about the app that started the CLI, usually
|
||||
# the terminal).
|
||||
sign --options runtime --entitlements "$HERE/entitlements.plist" "$CLI_DIR/lightcraft-cli"
|
||||
codesign --verify --strict --verbose=2 "$CLI_DIR/lightcraft-cli"
|
||||
rm -f "$CLI_ZIP"
|
||||
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
|
||||
|
||||
@@ -44,6 +44,26 @@
|
||||
</Property>
|
||||
<SetProperty Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="AppSearch" Sequence="both"
|
||||
Condition="PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
|
||||
<!-- Older MSIs (including 0.5.0) only recorded the executable in App Paths. Type="file"
|
||||
without a FileSearch returns its containing directory. Keep the searches separate so
|
||||
the newer InstallFolder value and an explicit INSTALLFOLDER always take precedence. -->
|
||||
<Property Id="LEGACYINSTALLFOLDER">
|
||||
<RegistrySearch Id="LegacyInstallFolder" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe" Type="file" />
|
||||
</Property>
|
||||
<!-- That directory search doesn't check the executable exists, and when its unquoted path is
|
||||
gone it falls back to the text before the first space: a deleted
|
||||
"C:\Program Files\LightCraft\lightcraft.exe" yields "C:\". Only use the folder when this
|
||||
search, which does require lightcraft.exe, finds it too. -->
|
||||
<Property Id="LEGACYINSTALLEXE">
|
||||
<RegistrySearch Id="LegacyInstallExe" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe" Type="file">
|
||||
<FileSearch Id="LegacyInstallExeFile" Name="lightcraft.exe" />
|
||||
</RegistrySearch>
|
||||
</Property>
|
||||
<SetProperty Id="INSTALLFOLDER" Action="SetInstallFolderFromAppPaths" Value="[LEGACYINSTALLFOLDER]"
|
||||
After="SetINSTALLFOLDER" Sequence="both"
|
||||
Condition="LEGACYINSTALLFOLDER AND LEGACYINSTALLEXE AND NOT PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
|
||||
<StandardDirectory Id="ProgramMenuFolder" />
|
||||
|
||||
<Feature Id="Main" Title="LightCraft" Level="1">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<#
|
||||
Exercise the install-folder searches and actions from lightcraft.wxs with real MSI AppSearch.
|
||||
Requires Windows and WiX 5.0.2. No application binaries, administrator rights, or real
|
||||
LightCraft installation are needed: a per-user probe writes only to a unique test registry key.
|
||||
Run: pwsh packaging/windows/test-install-folder.ps1 [-Arch x86|x64|arm64] [-Wix path/to/wix.exe]
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('x86', 'x64', 'arm64')][string] $Arch = 'x64',
|
||||
[string] $Wix = 'wix'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$id = [Guid]::NewGuid().ToString('N')
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) "lightcraft-install-folder-$id"
|
||||
$testsKey = 'Software\LightCraftInstallerTests'
|
||||
$key = "$testsKey\$id"
|
||||
$product = [Guid]::NewGuid().ToString('B')
|
||||
$upgrade = [Guid]::NewGuid().ToString('B')
|
||||
$view = if ($Arch -eq 'x86') { [Microsoft.Win32.RegistryView]::Registry32 } else { [Microsoft.Win32.RegistryView]::Registry64 }
|
||||
$registry = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::CurrentUser, $view)
|
||||
$installed = $false
|
||||
$passed = $false
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
|
||||
function Invoke-Msi([string[]] $Arguments, [string] $Log, [switch] $AllowAbsent) {
|
||||
$p = Start-Process msiexec.exe -ArgumentList ($Arguments + @('/qn', '/norestart', '/l*v', "`"$Log`"")) -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0 -and -not ($AllowAbsent -and $p.ExitCode -eq 1605)) {
|
||||
Get-Content $Log -Tail 40
|
||||
throw "msiexec exited $($p.ExitCode)"
|
||||
}
|
||||
}
|
||||
|
||||
function Set-TestValue([string] $Subkey, [string] $Name, [string] $Value) {
|
||||
$k = $registry.CreateSubKey("$key\$Subkey")
|
||||
try { $k.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) } finally { $k.Dispose() }
|
||||
}
|
||||
|
||||
try {
|
||||
[xml] $source = Get-Content (Join-Path $PSScriptRoot 'lightcraft.wxs') -Raw
|
||||
$ns = [Xml.XmlNamespaceManager]::new($source.NameTable)
|
||||
$ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs')
|
||||
# Copy the real search/action authoring, changing only registry roots/keys for isolation.
|
||||
# Do not reimplement folder resolution here: removing the fallback must fail the legacy case.
|
||||
$nodes = $source.SelectNodes('/w:Wix/w:Package/w:Property[w:RegistrySearch] | /w:Wix/w:Package/w:SetProperty[@Id="INSTALLFOLDER"]', $ns)
|
||||
$authoring = foreach ($node in $nodes) {
|
||||
$copy = $node.CloneNode($true)
|
||||
foreach ($search in $copy.SelectNodes('.//w:RegistrySearch', $ns)) {
|
||||
$search.SetAttribute('Root', 'HKCU')
|
||||
$search.SetAttribute('Key', "$key\$($search.GetAttribute('Key'))")
|
||||
}
|
||||
$copy.OuterXml
|
||||
}
|
||||
$directory = $source.SelectSingleNode('/w:Wix/w:Package/w:StandardDirectory[w:Directory[@Id="INSTALLFOLDER"]]', $ns).OuterXml
|
||||
$probe = @"
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="LightCraft folder search test $id" Manufacturer="LightCraft tests"
|
||||
Version="1.0.0" ProductCode="$product" UpgradeCode="$upgrade" Scope="perUser">
|
||||
$($authoring -join "`n")
|
||||
$directory
|
||||
<Feature Id="Main"><ComponentRef Id="Result" /></Feature>
|
||||
<Component Id="Result" Directory="INSTALLFOLDER" Guid="*">
|
||||
<RegistryValue Root="HKCU" Key="$key\Result" Name="Folder" Value="[INSTALLFOLDER]" Type="string" KeyPath="yes" />
|
||||
</Component>
|
||||
</Package>
|
||||
</Wix>
|
||||
"@
|
||||
$wxs = Join-Path $root 'probe.wxs'
|
||||
$msi = Join-Path $root 'probe.msi'
|
||||
$probe | Set-Content $wxs -Encoding utf8
|
||||
& $Wix build $wxs -arch $Arch -o $msi
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not build the folder-search probe' }
|
||||
|
||||
$legacy = Join-Path $root 'legacy app café'
|
||||
$modern = Join-Path $root 'remembered app'
|
||||
$explicit = Join-Path $root 'explicit app'
|
||||
$emptied = Join-Path $root 'emptied app'
|
||||
New-Item -ItemType Directory -Path $legacy, $modern, $explicit, $emptied | Out-Null
|
||||
$legacyExe = Join-Path $legacy 'lightcraft.exe'
|
||||
[IO.File]::WriteAllText($legacyExe, 'test placeholder; never executed')
|
||||
$programFiles = if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles }
|
||||
$cases = @(
|
||||
@{ Name = 'fresh'; Expected = (Join-Path $programFiles 'LightCraft') },
|
||||
@{ Name = 'legacy-app-paths'; Legacy = $legacyExe; Expected = $legacy },
|
||||
# A stale unquoted path with a space used to resolve to the parent of the text before the space.
|
||||
@{ Name = 'legacy-folder-deleted'; Legacy = (Join-Path $root 'deleted app\lightcraft.exe'); Expected = (Join-Path $programFiles 'LightCraft') },
|
||||
@{ Name = 'legacy-exe-deleted'; Legacy = (Join-Path $emptied 'lightcraft.exe'); Expected = (Join-Path $programFiles 'LightCraft') },
|
||||
@{ Name = 'remembered-folder'; Modern = $true; Expected = $modern },
|
||||
@{ Name = 'remembered-beats-legacy'; Modern = $true; Legacy = $legacyExe; Expected = $modern },
|
||||
@{ Name = 'explicit-beats-legacy'; Legacy = $legacyExe; Explicit = $true; Expected = $explicit },
|
||||
@{ Name = 'explicit-beats-both'; Modern = $true; Legacy = $legacyExe; Explicit = $true; Expected = $explicit }
|
||||
)
|
||||
foreach ($case in $cases) {
|
||||
$registry.DeleteSubKeyTree($key, $false)
|
||||
if ($case.Legacy) { Set-TestValue 'Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe' '' $case.Legacy }
|
||||
if ($case.Modern) { Set-TestValue 'Software\LightCraft' 'InstallFolder' "$modern\" }
|
||||
$arguments = @('/i', "`"$msi`"")
|
||||
if ($case.Explicit) { $arguments += "INSTALLFOLDER=`"$explicit`"" }
|
||||
# Also attempt cleanup if an installation only partially completes.
|
||||
$installed = $true
|
||||
Invoke-Msi $arguments (Join-Path $root "$($case.Name)-install.log")
|
||||
$result = $registry.OpenSubKey("$key\Result")
|
||||
if (-not $result) { throw "$($case.Name): no result recorded" }
|
||||
try { $actual = [string] $result.GetValue('Folder') } finally { $result.Dispose() }
|
||||
if ($actual.TrimEnd('\') -ine $case.Expected.TrimEnd('\')) {
|
||||
throw "$($case.Name): expected '$($case.Expected)', got '$actual'"
|
||||
}
|
||||
Invoke-Msi @('/x', $product) (Join-Path $root "$($case.Name)-uninstall.log")
|
||||
$installed = $false
|
||||
Write-Output "ok $Arch $($case.Name)"
|
||||
}
|
||||
$passed = $true
|
||||
} finally {
|
||||
try {
|
||||
if ($installed) { Invoke-Msi @('/x', $product) (Join-Path $root 'cleanup.log') -AllowAbsent }
|
||||
} finally {
|
||||
$registry.DeleteSubKeyTree($key, $false)
|
||||
$tests = $registry.OpenSubKey($testsKey)
|
||||
if ($tests) {
|
||||
$empty = $tests.SubKeyCount -eq 0 -and $tests.ValueCount -eq 0
|
||||
$tests.Dispose()
|
||||
if ($empty) { $registry.DeleteSubKey($testsKey, $false) }
|
||||
}
|
||||
$registry.Dispose()
|
||||
# Keep logs on failure; they explain MSI failures that otherwise only report an exit code.
|
||||
if ($passed) { Remove-Item -Recurse -Force $root } else { Write-Output "Test artifacts retained: $root" }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user