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:
commit
76b5377868
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}; каждое объединено в стопку с оригиналом"
|
||||
}
|
||||
|
|
|
|||
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 |
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
20
docs/mcp.md
20
docs/mcp.md
|
|
@ -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`
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue