diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 46ce3c90..f39de00b 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 12f046a3..5f2609a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 149a5c23..7163c757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index a82d1df7..8884f2bd 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 59b78f6a..63a4deb7 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -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 -o [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)> = @@ -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(()) } diff --git a/apps/lightcraft-web/src/store.rs b/apps/lightcraft-web/src/store.rs index 9d3e2199..729e7940 100644 --- a/apps/lightcraft-web/src/store.rs +++ b/apps/lightcraft-web/src/store.rs @@ -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", } } diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 6b297b94..e231c47c 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -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) } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 8059b1d6..f1523c37 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option { 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 { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 770a30df..cc71cc06 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -595,6 +595,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { 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] diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 4c93a89a..166fbf4d 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -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"] } diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e1998c49..4f9566b5 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -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), diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 36392dbc..6333a9c8 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -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; diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index 00a25fab..26e144ae 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -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 { 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 { 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, diff --git a/crates/codecs/src/jxl_encode.rs b/crates/codecs/src/jxl_encode.rs new file mode 100644 index 00000000..e76c5b14 --- /dev/null +++ b/crates/codecs/src/jxl_encode.rs @@ -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 { + 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> { + 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, alpha: bool }, + U16 { data: Vec, alpha: bool }, +} + +#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))] +impl Pixels { + fn from(img: &EncodeImage, colour: JxlColour) -> Result { + 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(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec, 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 = 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> { + 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}"))) +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index bd573248..c96c5334 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -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}; diff --git a/crates/codecs/tests/bench.rs b/crates/codecs/tests/bench.rs index 8bae6901..4c6f03fa 100644 --- a/crates/codecs/tests/bench.rs +++ b/crates/codecs/tests/bench.rs @@ -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 { diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 8aa6f06d..121b83e6 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -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#""#; + 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 = (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 = (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::>(), 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 = (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); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1c0e5766..2229c42a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 654b6db4..6fc61803 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -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, cancel: Option>, 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(m: &Mutex) -> 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> { @@ -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(); diff --git a/crates/engine/src/apple_photos.rs b/crates/engine/src/apple_photos.rs new file mode 100644 index 00000000..fd1c6f24 --- /dev/null +++ b/crates/engine/src/apple_photos.rs @@ -0,0 +1,1294 @@ +//! Add exported files to Apple Photos (macOS; issue #236). +//! +//! Photos is asked through its scripting interface, the way Apple documents for importing: a +//! constant AppleScript ([`SCRIPT`]) run by `/usr/bin/osascript` imports the files, into an album +//! (made when missing) if one is named, and returns the new media items' ids. Album names and file +//! paths are never written into the script: they reach it as separate arguments (`on run argv`), +//! so no name or path can change what the script does and nothing needs escaping. +//! +//! This sends Apple Events to Photos' import command; it does not synthesize keystrokes or clicks. +//! The first time, macOS asks the user whether LightCraft may control Photos (System Settings › +//! Privacy & Security › Automation). A packaged app needs the `com.apple.security.automation. +//! apple-events` entitlement and an `NSAppleEventsUsageDescription` for that (packaging/macos). +//! +//! An import can take minutes (and waits for the user to answer that prompt), so each one is a job +//! ([`ImportJob`], kept in [`Imports`]) that a front end may run on a worker and look at later +//! (`export.photosImports`). One runs at a time, and an export that will add its files reserves +//! the slot before it writes them ([`Reservation`]): a retry while one is reserved or running is +//! refused, not queued, so files are never sent twice by accident and an export is never turned +//! away after writing its files. +//! +//! The process runner is injectable ([`Runner`]): [`crate::Session::apple_photos`] holds the real one +//! on macOS (installed by [`crate::Session::with_fs`]) and nothing elsewhere; tests use fakes and +//! never start osascript. Not in the web build. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::activity::{Activity, Cancel, TaskGuard}; + +/// Run by osascript with `argv` = [`MARKER`], the album name (empty: none), then the files' POSIX +/// paths. Prints the imported media items' ids, one per line. +/// +/// The album is looked for by its exact name among the albums at the top level of the library +/// (Photos' `album` elements are every album, folders included, in no set order): albums inside +/// folders are never used. None there: a new top-level album is made. More than one: nothing is +/// imported (error [`AMBIGUOUS_ALBUM`]). +pub const SCRIPT: &str = r#"on run argv + if (count of argv) < 3 then error "LightCraft: no files to import" number -50 + set albumName to item 2 of argv + set mediaFiles to {} + repeat with i from 3 to (count of argv) + set end of mediaFiles to (POSIX file (item i of argv)) as alias + end repeat + set importedItems to {} + tell application "Photos" + with timeout of 7200 seconds + if albumName is not "" then + set topLevel to {} + repeat with candidate in (every album whose name is albumName) + set candidateName to name of candidate + set candidateParent to missing value + try + set candidateParent to parent of candidate + on error errText number errNum + -- no parent folder: the album is at the top level + if {-1728, -2763} does not contain errNum then error errText number errNum + end try + considering case + set sameName to (candidateName is albumName) + end considering + if sameName and candidateParent is missing value then set end of topLevel to contents of candidate + end repeat + if (count of topLevel) > 1 then error "LightCraft: several top-level albums have this name" number 23601 + if (count of topLevel) is 1 then + set targetAlbum to item 1 of topLevel + else + set targetAlbum to make new album named albumName + end if + end if + try + if albumName is "" then + set importedItems to import mediaFiles skip check duplicates false + else + set importedItems to import mediaFiles into targetAlbum skip check duplicates false + end if + on error errText number errNum + -- -2763: Photos returned nothing (it imported nothing); anything else is a real error + if errNum is not -2763 then error errText number errNum + end try + end timeout + end tell + try + if importedItems is missing value then set importedItems to {} + on error + set importedItems to {} + end try + set mediaIds to {} + tell application "Photos" + repeat with mediaItem in importedItems + set end of mediaIds to (id of mediaItem) + end repeat + end tell + set AppleScript's text item delimiters to linefeed + set output to mediaIds as text + set AppleScript's text item delimiters to "" + return output +end run"#; + +/// The first script argument, always the same: osascript's option parsing stops at the first +/// argument that isn't an option, so an album name or path starting with `-` is never read as one. +pub const MARKER: &str = "lightcraft-photos-import"; + +/// The error number [`SCRIPT`] raises when more than one top-level album has the name asked for. +pub const AMBIGUOUS_ALBUM: i64 = 23601; + +/// Where macOS keeps Photos. +pub const PHOTOS_APP: &str = "/System/Applications/Photos.app"; + +/// How many finished imports a session remembers (`export.photosImports`). +pub const KEPT_JOBS: usize = 32; + +/// Why there is no Photos runner in this session. +pub fn unavailable() -> &'static str { + if cfg!(target_os = "macos") { + "Apple Photos isn't connected in this session (it is in the desktop app and lightcraft-cli)" + } else { + "Apple Photos is only available on macOS" + } +} + +/// What osascript gave back. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Output { + /// The exit code (`None`: ended by a signal). + pub status: Option, + pub stdout: String, + pub stderr: String, +} + +/// Why osascript gave nothing back. +#[derive(Clone, Debug, PartialEq)] +pub enum RunError { + /// Photos isn't installed. + NoPhotos, + /// osascript couldn't be started. + Spawn(String), + /// Still running after this long: stopped. + Timeout(Duration), +} + +/// Runs osascript with these arguments, stopping it after the timeout. +pub type Runner = Arc Result + Send + Sync>; + +/// osascript's arguments: the script, one `-e` per line, then [`MARKER`], the album name and the +/// paths, each a whole argument of its own, unchanged. +pub fn osascript_args(album: &str, paths: &[String]) -> Vec { + let mut args = Vec::with_capacity(SCRIPT.lines().count() * 2 + 2 + paths.len()); + for line in SCRIPT.lines() { + args.push("-e".to_string()); + args.push(line.to_string()); + } + args.push(MARKER.to_string()); + args.push(album.to_string()); + args.extend(paths.iter().cloned()); + args +} + +/// The media item ids the script printed (one per line; blank lines ignored). +pub fn parse_ids(stdout: &str) -> Vec { + stdout.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect() +} + +/// The AppleScript error number at the end of osascript's message (`… (-1743)`). +fn error_number(stderr: &str) -> Option { + let s = stderr.trim_end(); + let open = s.rfind('(')?; + s.get(open + 1..)?.strip_suffix(')')?.trim().parse().ok() +} + +/// What to tell the user when osascript failed importing into `album`. +pub fn explain(out: &Output, album: &str) -> String { + match error_number(&out.stderr) { + Some(-1743 | -1744) => "LightCraft isn't allowed to control Photos. Allow it in System Settings › Privacy & Security › Automation \ + (turn on Photos under LightCraft, or under your terminal app for lightcraft-cli), then try again." + .into(), + Some(-600 | -10810 | -10814) => "Apple Photos couldn't be found or opened on this Mac.".into(), + Some(-128) => "The import was cancelled in Photos.".into(), + Some(-1712) => "Photos didn't answer in time; it may still be importing. Check Photos before trying again.".into(), + Some(-43) => "Photos couldn't find a file to add (it was moved or deleted after the export).".into(), + Some(AMBIGUOUS_ALBUM) => format!( + "More than one album at the top level of Photos is named “{album}”, so nothing was added. Rename one of them in Photos, or use another album name." + ), + _ => { + let detail = out.stderr.lines().map(str::trim).find(|l| !l.is_empty()); + match (detail, out.status) { + (Some(d), _) => format!("Adding to Apple Photos failed: {d}"), + (None, Some(code)) => format!("Adding to Apple Photos failed (osascript exited with {code})."), + (None, None) => "Adding to Apple Photos failed (osascript was stopped).".into(), + } + } + } +} + +/// What to tell the user when osascript gave nothing back. +pub fn explain_run_error(e: &RunError) -> String { + match e { + RunError::NoPhotos => "Apple Photos couldn't be found on this Mac.".into(), + RunError::Spawn(why) => format!("Couldn't run /usr/bin/osascript to add the photos to Apple Photos: {why}"), + RunError::Timeout(t) => { + format!("Photos didn't finish adding the photos within {} s; it may still be importing. Check Photos before trying again.", t.as_secs()) + } + } +} + +/// How long one import may take: ten minutes, plus five seconds a file, at most two hours. The ten +/// minutes are for a person: the first import waits for the user to answer macOS's permission +/// prompt, and Photos may ask about duplicates; a first try with one minute timed out while the +/// prompt was still up. +pub fn timeout_for(files: usize) -> Duration { + Duration::from_secs(600u64.saturating_add(5u64.saturating_mul(files as u64)).min(7200)) +} + +/// What an import gave. +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + /// The new media items in Photos. + pub ids: Vec, + /// Files asked for. + pub requested: usize, + pub album: Option, +} + +impl Imported { + /// When Photos added fewer files than asked: why that can be. + pub fn warning(&self) -> Option { + (self.ids.len() < self.requested).then(|| { + format!("Photos added {} of {} files; the others may be duplicates it skipped or files it couldn't read.", self.ids.len(), self.requested) + }) + } + + pub fn to_json(&self) -> Value { + let mut v = json!({"imported": self.ids.len(), "requested": self.requested, "ids": self.ids, "album": self.album}); + if let Some(w) = self.warning() { + v["warning"] = json!(w); + } + v + } +} + +/// `paths` (absolute paths of existing files) and `album` checked for an import; the album name +/// trimmed. Nothing is started for a bad request. +pub fn check(paths: &[String], album: &str) -> Result { + if paths.is_empty() { + return Err("no files to add to Apple Photos".into()); + } + if let Some(p) = paths.iter().find(|p| !std::path::Path::new(p.as_str()).is_absolute()) { + return Err(format!("not an absolute path: {p}")); + } + if let Some(p) = paths.iter().find(|p| p.contains('\0')) { + return Err(format!("a path can't contain a NUL character: {}", p.escape_debug())); + } + if let Some(p) = paths.iter().find(|p| !std::path::Path::new(p.as_str()).is_file()) { + return Err(format!("no such file: {p}")); + } + let album = album.trim(); + if album.contains('\0') { + return Err("an album name can't contain a NUL character".into()); + } + Ok(album.to_string()) +} + +/// Files per osascript run. The paths are arguments, and macOS limits a process's arguments to +/// 1 MiB in all: 200 paths of the longest length macOS opens (1,024 bytes; [`check`] finds no +/// file at a longer one) stay below a quarter of that. +pub const BATCH: usize = 200; + +/// Run osascript for a checked request (`album` trimmed; see [`check`]): [`BATCH`] files at a +/// time, all within [`timeout_for`] the whole request. The first run makes the album when it is +/// missing; the others find it. +fn execute(runner: &Runner, paths: &[String], album: &str) -> Result { + let limit = timeout_for(paths.len()); + let deadline = std::time::Instant::now() + limit; + let mut ids = Vec::new(); + for (i, batch) in paths.chunks(BATCH).enumerate() { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + let out = if left.is_zero() { Err(RunError::Timeout(limit)) } else { runner(&osascript_args(album, batch), left) }; + let failed = match out { + Ok(out) if out.status == Some(0) => { + ids.extend(parse_ids(&out.stdout)); + continue; + } + Ok(out) => explain(&out, album), + // the request's limit, not what was left of it for this batch + Err(RunError::Timeout(_)) => explain_run_error(&RunError::Timeout(limit)), + Err(e) => explain_run_error(&e), + }; + if i == 0 { + return Err(failed); + } + return Err(format!("{failed} (Photos had added {} of the {} files before that.)", ids.len(), paths.len())); + } + Ok(Imported { ids, requested: paths.len(), album: (!album.is_empty()).then(|| album.to_string()) }) +} + +/// Add `paths` (absolute paths of existing files) to Photos, into `album` when it isn't blank, +/// here and now. +pub fn import(runner: &Runner, paths: &[String], album: &str) -> Result { + let album = check(paths, album)?; + execute(runner, paths, &album) +} + +/// How an import ended. +#[derive(Clone, Debug, PartialEq)] +pub enum Outcome { + Imported(Imported), + /// Photos (or osascript) failed: why, for the user. + Failed(String), + /// Photos wasn't asked: the export that reserved the import was cancelled, wrote no files or + /// stopped early. + Skipped(String), +} + +/// One import: reserved by an export still writing its files, running, or finished. +#[derive(Debug)] +pub struct ImportJob { + pub id: u64, + pub album: Option, + /// Files asked for (0 while an export is still writing them). + requested: AtomicUsize, + /// Reserved by an export that hasn't handed over its files yet. + exporting: AtomicBool, + /// Whoever started it reports the outcome itself (an export's toast or result): the app + /// doesn't announce it again. + quiet: AtomicBool, + outcome: Mutex>, + announced: AtomicBool, + /// When Photos was asked (the import started): `None` while an export still writes its files. + started: Mutex>, +} + +impl ImportJob { + /// When Photos was asked; `None` while the export that reserved the job still writes. + pub fn started(&self) -> Option { + *self.started.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn finished(&self) -> bool { + self.outcome.lock().unwrap_or_else(PoisonError::into_inner).is_some() + } + + /// Record the outcome, unless there already is one. + fn finish(&self, o: Outcome) { + let mut g = self.outcome.lock().unwrap_or_else(PoisonError::into_inner); + if g.is_none() { + *g = Some(o); + } + self.exporting.store(false, Ordering::Relaxed); + } + + pub fn outcome(&self) -> Option { + self.outcome.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + pub fn requested(&self) -> usize { + self.requested.load(Ordering::Relaxed) + } + + pub fn quiet(&self) -> bool { + self.quiet.load(Ordering::Relaxed) + } + + /// True once: when the job has finished and nobody has announced it yet (never for a quiet one). + pub fn take_announcement(&self) -> bool { + !self.quiet() && self.finished() && !self.announced.swap(true, Ordering::Relaxed) + } + + /// Why another import can't start now. + fn busy(&self) -> String { + if self.exporting.load(Ordering::Relaxed) { + format!( + "An export is about to add its files to Apple Photos (import {}). Wait for it to finish (export.photosImports), then try again.", + self.id + ) + } else { + format!( + "Apple Photos is still adding {} file(s) from an earlier request (import {}). Wait for it to finish (export.photosImports), then try again.", + self.requested(), + self.id + ) + } + } + + /// `{job, running, requested, album}` (`exporting: true` while an export is still writing its + /// files), and once finished `{imported, ids, warning?}`, `{error}` or `{skipped}`. + pub fn json(&self) -> Value { + let mut v = json!({"job": self.id, "running": true, "requested": self.requested(), "album": self.album}); + if self.exporting.load(Ordering::Relaxed) { + v["exporting"] = json!(true); + } + match self.outcome() { + None => {} + Some(Outcome::Imported(i)) => { + if let (Some(o), Some(done)) = (v.as_object_mut(), i.to_json().as_object()) { + o.extend(done.clone()); + } + v["running"] = json!(false); + } + Some(Outcome::Failed(e)) => { + v["running"] = json!(false); + v["error"] = json!(e); + } + Some(Outcome::Skipped(why)) => { + v["running"] = json!(false); + v["skipped"] = json!(why); + } + } + v + } +} + +/// A session's imports (the last [`KEPT_JOBS`], oldest first), shared with the threads that run +/// them. +#[derive(Clone, Debug, Default)] +pub struct Imports(Arc>)>>); + +impl Imports { + fn jobs(&self) -> std::sync::MutexGuard<'_, (u64, Vec>)> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn all(&self) -> Vec> { + self.jobs().1.clone() + } + + pub fn get(&self, id: u64) -> Option> { + self.jobs().1.iter().find(|j| j.id == id).cloned() + } + + pub fn latest(&self) -> Option> { + self.jobs().1.last().cloned() + } + + /// The import that is reserved or running, if any (there is at most one). + pub fn running(&self) -> Option> { + self.jobs().1.iter().find(|j| !j.finished()).cloned() + } + + /// A new job, unless one is reserved or running: two would race in Photos, and a retry of one + /// that is still going would add its files twice. + fn begin(&self, requested: usize, album: &str, exporting: bool) -> Result, String> { + let mut g = self.jobs(); + if let Some(r) = g.1.iter().find(|j| !j.finished()) { + return Err(r.busy()); + } + g.0 = g.0.saturating_add(1); + let job = Arc::new(ImportJob { + id: g.0, + album: (!album.is_empty()).then(|| album.to_string()), + requested: AtomicUsize::new(requested), + exporting: AtomicBool::new(exporting), + quiet: AtomicBool::new(true), + outcome: Mutex::new(None), + announced: AtomicBool::new(false), + started: Mutex::new(None), + }); + g.1.push(job.clone()); + let excess = g.1.len().saturating_sub(KEPT_JOBS); + g.1.drain(..excess); + Ok(job) + } + + /// Reserve the import slot for an export that will add the files it writes: from now until + /// the export hands them over ([`Reservation::run`] / [`Reservation::start`]) or ends (the + /// reservation dropped), other imports are refused. An error while one is reserved or running. + pub fn reserve(&self, runner: Runner, album: &str) -> Result { + let album = album.trim(); + if album.contains('\0') { + return Err("an album name can't contain a NUL character".into()); + } + let job = self.begin(0, album, true)?; + Ok(Reservation { runner, album: album.to_string(), job: Some(job), activity: None }) + } + + /// [`Imports::wait_idle_within`] with the [`WaitLimits::default`]. + pub fn wait_idle(&self) -> bool { + self.wait_idle_within(WaitLimits::default()) + } + + /// Wait until no import is reserved or running: a process about to end (lightcraft-cli, an + /// MCP server at EOF, a snapshot script) calls this so it never abandons osascript or an + /// import's result. The deadline follows the job as it goes: while an export still writes + /// the files it reserved the import for, `limits.writing` from the start of the wait; once + /// Photos was asked, the import's own time limit (`limits.import`, the one osascript runs + /// under) plus `limits.margin` from when it started; never longer than `limits.cap` in all. + /// True when nothing is left reserved or running. + pub fn wait_idle_within(&self, limits: WaitLimits) -> bool { + let t0 = std::time::Instant::now(); + loop { + let Some(job) = self.running() else { return true }; + let waited = t0.elapsed(); + if waited >= limits.cap { + return false; + } + let expired = match job.started() { + None => waited >= limits.writing, + Some(at) => at.elapsed() >= (limits.import)(job.requested()).saturating_add(limits.margin), + }; + if expired { + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +/// How long [`Imports::wait_idle_within`] waits. +#[derive(Clone, Copy, Debug)] +pub struct WaitLimits { + /// An export still writing the files it will add: how much longer it may write. + pub writing: Duration, + /// A running import's time limit for this many files. + pub import: fn(usize) -> Duration, + /// Added to that limit. + pub margin: Duration, + /// The longest the whole wait may take. + pub cap: Duration, +} + +impl Default for WaitLimits { + /// An hour of writing (what `lightcraft-cli snapshot` gives a background export), then the + /// import's osascript limit ([`timeout_for`], at most two hours) and half a minute; three hours + /// and a minute in all. + fn default() -> Self { + Self { writing: Duration::from_secs(3600), import: timeout_for, margin: Duration::from_secs(30), cap: Duration::from_secs(3600 + 7200 + 60) } + } +} + +impl crate::Session { + /// Before a front end without a window ends (lightcraft-cli, the MCP server, a headless + /// snapshot), on every exit path: wait for an Apple Photos import still reserved or running + /// (`export.addToPhotos {wait: false}`, a background export still writing), bounded + /// ([`Imports::wait_idle`], [`WaitLimits::default`]), so the process never abandons osascript + /// or the import's result. True when it had to wait. + pub fn wait_for_photos_imports(&self) -> bool { + let Some(job) = self.apple_photos_imports.running() else { return false }; + log::warn!("Apple Photos: waiting for import {} to finish before exiting", job.id); + if !self.apple_photos_imports.wait_idle() { + log::warn!("Apple Photos: gave up waiting for import {}; Photos may still be importing", job.id); + } + true + } +} + +/// The activity stack's kind for an Apple Photos import of its own (`activity.list`). +pub const ACTIVITY_KIND: &str = "applePhotos"; +/// ...and its label (translated by the frontend). +pub const ACTIVITY_LABEL: &str = "Adding to Apple Photos"; + +/// A row in the activity stack for an import that runs on its own (not as the last step of an +/// export, whose row covers it): no ✕ (Photos can't be stopped once asked), no count (Photos +/// reports none until it is done), the album as its detail. +fn row(activity: Option<&Activity>, album: &str) -> Option { + let row = activity?.start(ACTIVITY_KIND, ACTIVITY_LABEL, Cancel::No); + row.progress(0, 0); + if !album.is_empty() { + row.detail(album); + } + Some(row) +} + +/// Ask Photos to import `paths` (checked; `album` trimmed) for `job`: here, or on a worker thread, +/// holding `row` (the activity stack's) until Photos is done. +fn launch(runner: &Runner, job: &Arc, paths: Vec, album: String, background: bool, row: Option) { + job.requested.store(paths.len(), Ordering::Relaxed); + *job.started.lock().unwrap_or_else(PoisonError::into_inner) = Some(std::time::Instant::now()); + job.exporting.store(false, Ordering::Relaxed); + if !background { + let _row = row; + run_to_completion(runner, job, &paths, &album); + return; + } + let (r, j) = (runner.clone(), job.clone()); + let started = std::thread::Builder::new().name("apple-photos".into()).spawn(move || { + let _row = row; + run_to_completion(&r, &j, &paths, &album) + }); + if let Err(e) = started { + job.finish(Outcome::Failed(format!("Couldn't start adding the photos to Apple Photos: {e}"))); + } +} + +/// The message for an import whose runner panicked. +const STOPPED: &str = "Adding to Apple Photos stopped unexpectedly."; + +/// Finishes its job as failed when dropped without an outcome: however the work ends (a panic +/// unwinding through it included), the job finishes and the import slot is freed. +struct Completion<'a>(&'a ImportJob); + +impl Drop for Completion<'_> { + fn drop(&mut self) { + self.0.finish(Outcome::Failed(STOPPED.into())); + } +} + +/// Run the import for `job` and record its outcome, on this thread. A panic in the runner is +/// that import's error, never a job that stays "running" (holding the slot) or a panic for the +/// caller. +fn run_to_completion(runner: &Runner, job: &ImportJob, paths: &[String], album: &str) { + let _done = Completion(job); + let out = crate::guard::catch("the Apple Photos import", || execute(runner, paths, album)).unwrap_or_else(|_| Err(STOPPED.into())); + job.finish(out.map_or_else(Outcome::Failed, Outcome::Imported)); +} + +/// Add `paths` to Photos as a job of `imports`: on a worker thread (`background`; the job is +/// returned running, and announced by the app when it ends) or here (returned finished), with a +/// row in `activity` while Photos works. An error, and no job, for a bad request or while +/// another import is reserved or running. +pub fn import_job( + runner: &Runner, + imports: &Imports, + paths: Vec, + album: &str, + background: bool, + activity: Option<&Activity>, +) -> Result, String> { + let album = check(&paths, album)?; + let job = imports.begin(paths.len(), &album, false)?; + job.quiet.store(!background, Ordering::Relaxed); + let row = row(activity, &album); + launch(runner, &job, paths, album, background, row); + Ok(job) +} + +/// The import slot an export holds while it writes its files ([`Imports::reserve`]). Handed the +/// files with [`Reservation::run`] or [`Reservation::start`]; dropped unused (the export failed, +/// was refused or never started) it frees the slot, the job ending as skipped. +pub struct Reservation { + runner: Runner, + album: String, + job: Option>, + /// Where [`Reservation::start`]'s import shows while Photos works. + activity: Option, +} + +impl Reservation { + /// Show the import [`Reservation::start`] leaves running in `activity`. ([`Reservation::run`] + /// runs on the export's worker, and the export's own row covers it.) + pub fn shown_in(mut self, activity: Activity) -> Self { + self.activity = Some(activity); + self + } + + pub fn job(&self) -> Option<&Arc> { + self.job.as_ref() + } + + /// The absolute paths of the files an export wrote (`files`, as `export_batch` reports them: + /// entries with a `path`; skipped and failed ones are left out), or why there is nothing to do. + fn take(&mut self, files: &[Value], cancelled: bool) -> Result<(Arc, Vec), Value> { + let Some(job) = self.job.take() else { return Err(json!({"error": "this import was already handed its files"})) }; + let paths: Vec = files.iter().filter_map(|f| f.get("path").and_then(Value::as_str)).map(absolute).collect(); + let skipped = if cancelled { + Some("the export was cancelled") + } else if paths.is_empty() { + Some("the export wrote no files") + } else { + None + }; + if let Some(why) = skipped { + job.finish(Outcome::Skipped(why.into())); + return Err(job.json()); + } + if let Err(e) = check(&paths, &self.album) { + job.finish(Outcome::Failed(e)); + return Err(job.json()); + } + Ok((job, paths)) + } + + /// Add the files an export wrote and wait for Photos (a worker thread, or a caller without a + /// window). The finished job's [`ImportJob::json`]; never an error for the export. + pub fn run(mut self, files: &[Value], cancelled: bool) -> Value { + match self.take(files, cancelled) { + Ok((job, paths)) => { + launch(&self.runner, &job, paths, self.album.clone(), false, None); + job.json() + } + Err(done) => done, + } + } + + /// Like [`Reservation::run`] without waiting: Photos works on a worker thread; the running + /// job is returned, and the app announces its outcome. + pub fn start(mut self, files: &[Value]) -> Value { + match self.take(files, false) { + Ok((job, paths)) => { + job.quiet.store(false, Ordering::Relaxed); + let row = row(self.activity.as_ref(), &self.album); + launch(&self.runner, &job, paths, self.album.clone(), true, row); + job.json() + } + Err(done) => done, + } + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + if let Some(job) = self.job.take() { + job.finish(Outcome::Skipped("the export ended before writing its files".into())); + } + } +} + +/// `path` made absolute against the working directory (export folders may be given relative). +fn absolute(path: &str) -> String { + let p = std::path::Path::new(path); + if p.is_absolute() { + return path.to_string(); + } + std::env::current_dir().map(|d| d.join(p).to_string_lossy().to_string()).unwrap_or_else(|_| path.to_string()) +} + +/// The real runner: `/usr/bin/osascript`, stopped after the timeout. +#[cfg(target_os = "macos")] +pub fn osascript() -> Runner { + Arc::new(|args: &[String], timeout: Duration| { + use std::io::Read; + use std::process::{Command, Stdio}; + if !std::path::Path::new(PHOTOS_APP).exists() { + return Err(RunError::NoPhotos); + } + let mut child = Command::new("/usr/bin/osascript") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| RunError::Spawn(e.to_string()))?; + // read both pipes while it runs: a long list of ids must not fill a pipe and stall it + fn drain(r: Option) -> std::io::Result> { + std::thread::Builder::new().name("osascript-output".into()).spawn(move || { + let mut buf = Vec::new(); + if let Some(mut r) = r { + let _ = r.read_to_end(&mut buf); + } + String::from_utf8_lossy(&buf).into_owned() + }) + } + let pipes = drain(child.stdout.take()).and_then(|out| Ok((out, drain(child.stderr.take())?))); + let (stdout, stderr) = match pipes { + Ok(p) => p, + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunError::Spawn(e.to_string())); + } + }; + let deadline = std::time::Instant::now().checked_add(timeout); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if deadline.is_some_and(|d| std::time::Instant::now() >= d) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunError::Timeout(timeout)); + } + Ok(None) => std::thread::sleep(Duration::from_millis(100)), + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunError::Spawn(e.to_string())); + } + } + }; + Ok(Output { status: status.code(), stdout: stdout.join().unwrap_or_default(), stderr: stderr.join().unwrap_or_default() }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A runner that records the arguments it got and answers `answer`. + fn fake(answer: Result) -> (Runner, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let c = calls.clone(); + let r: Runner = Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + answer.clone() + }); + (r, calls) + } + + fn ok(stdout: &str) -> Result { + Ok(Output { status: Some(0), stdout: stdout.into(), stderr: String::new() }) + } + + fn failed(stderr: &str) -> Output { + Output { status: Some(1), stdout: String::new(), stderr: stderr.into() } + } + + /// The script part of an argument list: everything before the marker. + fn script_of(args: &[String]) -> &[String] { + let at = args.iter().position(|a| a == MARKER).unwrap(); + &args[..at] + } + + #[test] + fn names_and_paths_are_arguments_never_script() { + let hostile = [ + "", + "Holiday \"2026\"", + "back\\slash\\", + "line\nbreak\r\nand\ttab", + "end tell\ndo shell script \"rm -rf ~\"", + "-e", + "--help", + "日本の写真 📷 Ünïcödé", + "'single' & \"double\" » «", + ]; + let base = osascript_args("", &["/tmp/a.jpg".into()]); + for album in hostile { + let paths = vec!["/tmp/with \"quotes\".jpg".to_string(), "/tmp/new\nline/é\\x.jpg".to_string(), format!("/tmp/-{album}.jpg")]; + let args = osascript_args(album, &paths); + // the script is the same whatever the input… + assert_eq!(script_of(&args), script_of(&base)); + assert!(script_of(&args).chunks(2).all(|c| c[0] == "-e" && SCRIPT.lines().any(|l| l == c[1]))); + // …and the input follows the marker as whole arguments, unchanged + let at = args.iter().position(|a| a == MARKER).unwrap(); + assert_eq!(&args[at + 1], album); + assert_eq!(&args[at + 2..], &paths[..]); + } + // the script reads them from argv only + assert!(SCRIPT.starts_with("on run argv") && SCRIPT.contains("item 2 of argv") && SCRIPT.contains("item i of argv")); + assert!(!SCRIPT.contains("do shell script")); + // it imports into the album (made when missing) without skipping Photos' duplicate check + assert!(SCRIPT.contains("make new album named albumName")); + assert!(SCRIPT.contains("import mediaFiles into targetAlbum skip check duplicates false")); + } + + /// Photos' `album` elements are every album, those in folders too, in no set order: the script + /// keeps the top-level ones of exactly that name and refuses when there are several. + #[test] + fn the_album_is_a_single_top_level_one() { + let lookup = SCRIPT.find("every album whose name is albumName").unwrap(); + let choose = SCRIPT.find("set targetAlbum to item 1 of topLevel").unwrap(); + let make = SCRIPT.find("make new album named albumName").unwrap(); + let import = SCRIPT.find("import mediaFiles into targetAlbum").unwrap(); + assert!(lookup < choose && choose < make && make < import); + let filter = &SCRIPT[lookup..choose]; + assert!(filter.contains("parent of candidate") && filter.contains("candidateParent is missing value"), "{filter}"); + assert!(filter.contains("considering case"), "exact names: {filter}"); + assert!(filter.contains(&format!( + "if (count of topLevel) > 1 then error \"LightCraft: several top-level albums have this name\" number {AMBIGUOUS_ALBUM}" + ))); + let m = explain(&failed(&format!("execution error: LightCraft: several top-level albums have this name ({AMBIGUOUS_ALBUM})")), "Trip"); + assert!(m.contains("More than one album at the top level of Photos is named “Trip”") && m.contains("nothing was added"), "{m}"); + } + + #[test] + fn ids_are_one_per_line() { + assert_eq!(parse_ids("A1B2/L0/001\nC3D4/L0/001\n"), vec!["A1B2/L0/001", "C3D4/L0/001"]); + assert_eq!(parse_ids(" X/L0/001 \r\n\r\n Y/L0/001"), vec!["X/L0/001", "Y/L0/001"]); + assert!(parse_ids("").is_empty() && parse_ids("\n \n").is_empty()); + } + + #[test] + fn errors_say_what_to_do() { + let says = |stderr: &str, part: &str| { + let m = explain(&failed(stderr), "Trip"); + assert!(m.contains(part), "{stderr:?}: {m}"); + }; + says("146:180: execution error: Not authorized to send Apple events to Photos. (-1743)", "System Settings › Privacy & Security › Automation"); + says("execution error: Photos got an error: … (-1744)", "Automation"); + says("execution error: Can’t get application \"Photos\". (-10814)", "couldn't be found"); + says("execution error: Photos got an error: Application isn’t running. (-600)", "couldn't be found or opened"); + says("execution error: User canceled. (-128)", "cancelled"); + says("execution error: Photos got an error: AppleEvent timed out. (-1712)", "didn't answer in time"); + says("execution error: File not found (-43)", "couldn't find a file"); + says("execution error: Photos got an error: Something odd happened. (-2700)", "Something odd happened"); + assert!(explain(&Output { status: Some(3), ..Default::default() }, "").contains("exited with 3")); + assert!(explain(&Output { status: None, ..Default::default() }, "").contains("stopped")); + assert!(explain_run_error(&RunError::NoPhotos).contains("couldn't be found")); + assert!(explain_run_error(&RunError::Spawn("denied".into())).contains("denied")); + assert!(explain_run_error(&RunError::Timeout(Duration::from_secs(65))).contains("65 s")); + // a stray parenthesis is not an error number + assert!(explain(&failed("execution error: (not a number)"), "").contains("not a number")); + } + + fn files(tag: &str, n: usize) -> (std::path::PathBuf, Vec) { + let dir = std::env::temp_dir().join(format!("lc-photos-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = (0..n) + .map(|i| { + let p = dir.join(format!("photo {i} é.jpg")); + std::fs::write(&p, b"jpeg").unwrap(); + p.to_string_lossy().to_string() + }) + .collect(); + (dir, paths) + } + + #[test] + fn an_import_passes_the_files_and_returns_the_ids() { + let (dir, paths) = files("import", 2); + let (runner, calls) = fake(ok("ID-1/L0/001\nID-2/L0/001\n")); + let got = import(&runner, &paths, " Trip “2026” ").unwrap(); + assert_eq!(got, Imported { ids: vec!["ID-1/L0/001".into(), "ID-2/L0/001".into()], requested: 2, album: Some("Trip “2026”".into()) }); + assert_eq!(got.warning(), None); + let args = calls.lock().unwrap()[0].clone(); + let at = args.iter().position(|a| a == MARKER).unwrap(); + assert_eq!(&args[at + 1..], &[vec!["Trip “2026”".to_string()], paths.clone()].concat()[..]); + // fewer back than asked: a partial import, said so + let (runner, _) = fake(ok("ID-1/L0/001\n")); + let got = import(&runner, &paths, "").unwrap(); + assert_eq!((got.ids.len(), got.album.clone()), (1, None)); + assert!(got.warning().unwrap().contains("1 of 2")); + assert_eq!(got.to_json()["warning"], json!(got.warning().unwrap())); + // a failed run is the mapped message + let (runner, _) = fake(Ok(failed("execution error: Not authorized to send Apple events to Photos. (-1743)"))); + assert!(import(&runner, &paths, "x").unwrap_err().contains("Automation")); + // a timeout names the request's limit + let (runner, _) = fake(Err(RunError::Timeout(Duration::from_secs(70)))); + assert!(import(&runner, &paths, "x").unwrap_err().contains(&format!("{} s", timeout_for(2).as_secs()))); + let _ = std::fs::remove_dir_all(dir); + } + + /// Codex review of #643: every path was an argument of one osascript run, so a big export + /// (10,000 files) went over macOS's 1 MiB argument limit and Photos got nothing. + #[test] + fn big_imports_run_in_batches_within_the_argument_limit() { + let long = format!("/{}", "d/".repeat(500)); // about 1,000 bytes, near the longest path macOS opens + let paths: Vec = (0..10_000).map(|i| format!("{long}{i:05}.jpg")).collect(); + let runs = Arc::new(Mutex::new(Vec::<(usize, usize, Duration)>::new())); + let r = runs.clone(); + let runner: Runner = Arc::new(move |args: &[String], limit| { + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + r.lock().unwrap().push((files, args.iter().map(|a| a.len() + 1).sum(), limit)); + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + let got = execute(&runner, &paths, "Big").unwrap(); + assert_eq!((got.ids.len(), got.requested, got.warning()), (10_000, 10_000, None)); + let runs = runs.lock().unwrap().clone(); + assert_eq!(runs.len(), 10_000 / BATCH); + assert!(runs.iter().all(|&(files, _, _)| files == BATCH)); + let biggest = runs.iter().map(|&(_, bytes, _)| bytes).max().unwrap(); + assert!(biggest < 1 << 18, "{biggest} bytes of arguments in one run"); + // every run gets what is left of the request's limit, never more + assert!(runs.iter().all(|&(_, _, limit)| limit <= timeout_for(10_000))); + + // a later batch failing: the error says how far Photos got + let calls = Arc::new(Mutex::new(0)); + let c = calls.clone(); + let runner: Runner = Arc::new(move |args: &[String], _| { + let mut n = c.lock().unwrap(); + *n += 1; + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + if *n == 2 { + return Ok(failed("execution error: Photos got an error: User canceled. (-128)")); + } + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + let e = execute(&runner, &paths[..450], "Big").unwrap_err(); + assert!(e.contains("cancelled") && e.contains("200 of the 450 files"), "{e}"); + assert_eq!(*calls.lock().unwrap(), 2, "nothing more is sent after a failed batch"); + + // the batches share the request's one deadline: time a batch takes comes off the next one's + let limits = Arc::new(Mutex::new(Vec::::new())); + let l = limits.clone(); + let runner: Runner = Arc::new(move |args: &[String], limit| { + l.lock().unwrap().push(limit); + std::thread::sleep(Duration::from_millis(300)); + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + execute(&runner, &paths[..450], "Big").unwrap(); + let limits = limits.lock().unwrap().clone(); + assert_eq!(limits.len(), 3); + let whole = timeout_for(450); + assert!(limits[0] <= whole && limits[0] > whole - Duration::from_millis(250), "{limits:?}"); + assert!(limits[1] <= whole - Duration::from_millis(300) && limits[2] <= whole - Duration::from_millis(600), "{limits:?}"); + } + + #[test] + fn bad_requests_never_reach_osascript() { + let (dir, paths) = files("bad", 1); + let (runner, calls) = fake(ok("")); + assert!(import(&runner, &[], "").unwrap_err().contains("no files")); + assert!(import(&runner, &["relative/a.jpg".into()], "").unwrap_err().contains("absolute")); + assert!(import(&runner, &[format!("{}/missing.jpg", dir.display())], "").unwrap_err().contains("no such file")); + assert!(import(&runner, &[format!("{}/a\0b.jpg", dir.display())], "").unwrap_err().contains("NUL")); + assert!(import(&runner, &paths, "a\0b").unwrap_err().contains("NUL")); + assert!(calls.lock().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn after_an_export_the_written_files_are_added() { + let (dir, paths) = files("after", 2); + let exported = vec![ + json!({"path": paths[0], "width": 10}), + json!({"skipped": "/elsewhere/x.jpg"}), + json!({"photo": 3, "error": "x"}), + json!({"path": paths[1]}), + ]; + let (runner, calls) = fake(ok("A/L0/001\nB/L0/001")); + let imports = Imports::default(); + let out = imports.reserve(runner.clone(), " Album ").unwrap().run(&exported, false); + assert_eq!(out["imported"], 2); + assert_eq!(out["album"], "Album"); + assert_eq!((out["job"].as_u64(), out["running"].as_bool()), (Some(1), Some(false))); + let args = calls.lock().unwrap()[0].clone(); + assert_eq!(&args[args.len() - 2..], &paths[..], "only the files written, in order"); + // nothing written, or cancelled: Photos isn't asked, and the slot is free again + let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false); + assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}"); + let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true); + assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}"); + assert_eq!(calls.lock().unwrap().len(), 1); + assert!(imports.running().is_none()); + let _ = std::fs::remove_dir_all(dir); + } + + /// An export that will add its files reserves the slot before writing them: a competing + /// import is refused, naming the export's job, and the export is never turned away after + /// writing. Every way the export can end frees the slot. + #[test] + fn an_export_reserves_the_import_before_writing() { + let (dir, paths) = files("reserve", 1); + let (runner, calls) = fake(ok("A\n")); + let imports = Imports::default(); + let reserved = imports.reserve(runner.clone(), "Trip").unwrap(); + assert_eq!(reserved.job().map(|j| j.json()), Some(json!({"job": 1, "running": true, "exporting": true, "requested": 0, "album": "Trip"}))); + // while the export writes: other imports and exports are refused, naming it + let e = import_job(&runner, &imports, paths.clone(), "", false, None).unwrap_err(); + assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}"); + assert!(imports.reserve(runner.clone(), "").err().unwrap().contains("(import 1)")); + assert!(calls.lock().unwrap().is_empty()); + // the export hands over its files: they are imported under its job + let out = reserved.run(&[json!({"path": paths[0]})], false); + assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out.get("exporting")), (Some(1), Some(1), None), "{out}"); + // an export that fails or never runs (its reservation dropped) frees the slot + let dropped = imports.reserve(runner.clone(), "").unwrap(); + let id = dropped.job().unwrap().id; + drop(dropped); + let j = imports.get(id).unwrap(); + assert!(j.finished() && !j.take_announcement(), "skipped quietly"); + assert!(j.json()["skipped"].as_str().unwrap().contains("ended before writing")); + // a file the export reported but that is gone: Photos isn't asked, the slot is freed + let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"path": format!("{}/gone.jpg", dir.display())})], false); + assert!(out["error"].as_str().unwrap().contains("no such file"), "{out}"); + assert_eq!(calls.lock().unwrap().len(), 1); + assert!(imports.running().is_none()); + assert_eq!(import_job(&runner, &imports, paths, "", false, None).unwrap().id, 4); + let _ = std::fs::remove_dir_all(dir); + } + + /// Review of #236: a runner that panics, here or on a worker, ends its import as failed and + /// frees the slot; it never stays "running", refusing every later import. + #[test] + fn a_panicking_runner_frees_the_slot() { + let (dir, paths) = files("panic", 1); + let panicky: Runner = Arc::new(|_: &[String], _| panic!("runner exploded")); + let (good, _) = fake(ok("A\n")); + let imports = Imports::default(); + let stopped = |j: &ImportJob| j.json()["error"].as_str().is_some_and(|e| e.contains("stopped unexpectedly")); + // waiting here + let job = import_job(&panicky, &imports, paths.clone(), "", false, None).unwrap(); + assert!(job.finished() && stopped(&job), "{}", job.json()); + assert!(imports.running().is_none()); + // on a worker + let job = import_job(&panicky, &imports, paths.clone(), "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20)); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(stopped(&job), "{}", job.json()); + // an export's reservation, waiting and not + let out = imports.reserve(panicky.clone(), "").unwrap().run(&[json!({"path": paths[0]})], false); + assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}"); + assert!(imports.running().is_none()); + let started = imports.reserve(panicky, "").unwrap().start(&[json!({"path": paths[0]})]); + let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20)); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(stopped(&job)); + // the slot is free: the next import runs + assert_eq!(import_job(&good, &imports, paths, "", false, None).unwrap().json()["imported"], 1); + let _ = std::fs::remove_dir_all(dir); + } + + /// A process about to end waits for an import still running, so it never abandons osascript. + #[test] + fn waiting_for_idle_outlasts_a_running_import() { + let (dir, paths) = files("idle", 1); + let (runner, go) = delayed(); + let imports = Imports::default(); + let job = import_job(&runner, &imports, paths, "", true, None).unwrap(); + assert!(!job.finished()); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + go.send(()).unwrap(); + }); + imports.wait_idle(); + assert!(job.finished() && imports.running().is_none()); + release.join().unwrap(); + // nothing running: returns at once + imports.wait_idle(); + let _ = std::fs::remove_dir_all(dir); + } + + /// Review of #236: shutdown while an export is still writing the files it reserved the import + /// for. The deadline follows the job: the write allowance while the export writes, then the + /// import's own time limit from when Photos was asked, not one deadline fixed at the start + /// (which gave up with the import still inside its limit). Scaled down: 300 ms of writing, + /// imports limited to 1.5 s (+ 0.1 s). + #[test] + fn shutdown_follows_an_export_into_its_import() { + let (dir, paths) = files("shutdown", 2); + let limits = WaitLimits { + writing: Duration::from_millis(300), + import: |_| Duration::from_millis(1500), + margin: Duration::from_millis(100), + cap: Duration::from_secs(20), + }; + let taking = |ms: u64| -> Runner { + Arc::new(move |args: &[String], _| { + std::thread::sleep(Duration::from_millis(ms)); + let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + }) + }; + let imports = Imports::default(); + // the export is still writing when the wait begins; Photos then takes 1 s, past the write + // allowance but inside the import's own limit + let reserved = imports.reserve(taking(1000), "").unwrap(); + let written: Vec = paths.iter().map(|p| json!({"path": p})).collect(); + let exporter = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + reserved.run(&written, false) + }); + let t0 = std::time::Instant::now(); + assert!(imports.wait_idle_within(limits), "waited for the import, not just the write allowance"); + assert!(t0.elapsed() >= Duration::from_millis(1000), "{:?}", t0.elapsed()); + assert_eq!(exporter.join().unwrap()["imported"], 2); + // an import past its own limit is given up on at its deadline (not the cap) + let job = import_job(&taking(3000), &imports, paths.clone(), "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(limits)); + let waited = t0.elapsed(); + assert!(waited >= Duration::from_millis(1500) && waited < Duration::from_millis(2900), "{waited:?}"); + while !job.finished() { + std::thread::sleep(Duration::from_millis(20)); + } + // an export that never hands its files over: the write allowance, then it's left + let held = imports.reserve(taking(0), "").unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(limits)); + let waited = t0.elapsed(); + assert!(waited >= Duration::from_millis(300) && waited < Duration::from_millis(1500), "{waited:?}"); + drop(held); + assert!(imports.wait_idle_within(limits)); + // and the cap holds over everything + let job = import_job(&taking(1000), &imports, paths, "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(WaitLimits { cap: Duration::from_millis(200), ..limits })); + assert!(t0.elapsed() < Duration::from_millis(900)); + while !job.finished() { + std::thread::sleep(Duration::from_millis(20)); + } + let _ = std::fs::remove_dir_all(dir); + } + + /// A runner that waits for `go` (at most 20 s, so a regression fails instead of hanging), then + /// answers one id per file. + fn delayed() -> (Runner, std::sync::mpsc::Sender<()>) { + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = Mutex::new(wait); + let r: Runner = Arc::new(move |args: &[String], _| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + (r, go) + } + + /// Imports run as jobs: in the background the caller gets the job at once and its outcome + /// later; while one runs a second is refused (a retry must not add the files twice). + #[test] + fn a_slow_import_runs_as_a_job_and_refuses_a_second_one() { + let (dir, paths) = files("job", 2); + let (runner, go) = delayed(); + let imports = Imports::default(); + let t0 = std::time::Instant::now(); + let job = import_job(&runner, &imports, paths.clone(), " Trip ", true, None).unwrap(); + assert!(t0.elapsed() < Duration::from_secs(5), "the caller doesn't wait for Photos"); + assert_eq!(job.json(), json!({"job": 1, "running": true, "requested": 2, "album": "Trip"})); + assert_eq!(imports.running().map(|j| j.id), Some(1)); + let e = import_job(&runner, &imports, paths.clone(), "", true, None).unwrap_err(); + assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}"); + let e = imports.reserve(runner.clone(), "").err().unwrap(); + assert!(e.contains("still adding"), "{e}"); + go.send(()).unwrap(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20), "the job finishes"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(job.take_announcement() && !job.take_announcement(), "announced once"); + let done = imports.get(1).unwrap().json(); + assert_eq!((done["running"].as_bool(), done["imported"].as_u64(), done["album"].as_str()), (Some(false), Some(2), Some("Trip")), "{done}"); + // finished: the next one may start; a failed one says why + let (fails, _) = fake(Err(RunError::Spawn("denied".into()))); + let job = import_job(&fails, &imports, paths, "", false, None).unwrap(); + assert_eq!(job.id, 2); + assert!(job.json()["error"].as_str().unwrap().contains("denied")); + assert!(imports.running().is_none() && imports.latest().is_some_and(|j| j.id == 2)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_session_remembers_its_last_imports() { + let (dir, paths) = files("kept", 1); + let (runner, _) = fake(ok("A\n")); + let imports = Imports::default(); + for _ in 0..KEPT_JOBS + 3 { + import_job(&runner, &imports, paths.clone(), "", false, None).unwrap(); + } + let all = imports.all(); + assert_eq!(all.len(), KEPT_JOBS); + assert_eq!((all[0].id, all[KEPT_JOBS - 1].id), (4, KEPT_JOBS as u64 + 3)); + assert!(imports.get(1).is_none()); + let _ = std::fs::remove_dir_all(dir); + } + + /// The activity stack (issue #345): an import of its own shows a row while Photos works (no ✕, + /// no count, the album as detail) and loses it when Photos is done; an export's own import + /// shows none of its own, the export's row covers it. + #[test] + fn imports_of_their_own_show_in_the_activity_stack() { + let (dir, paths) = files("activity", 2); + let activity = Activity::default(); + let seen = Arc::new(Mutex::new(Vec::new())); + // Photos answers when the test lets it (`go`), so no assertion races the import's end + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = Mutex::new(wait); + let (a, rows) = (activity.clone(), seen.clone()); + let runner: Runner = Arc::new(move |args: &[String], _| { + rows.lock().unwrap().push(a.list()); + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let n = args.len() - args.iter().position(|x| x == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + }); + let imports = Imports::default(); + let ended = |job: &ImportJob| { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !(job.finished() && activity.list().is_empty()) { + assert!(std::time::Instant::now() < deadline, "the import ends and its row goes"); + std::thread::sleep(Duration::from_millis(5)); + } + }; + // waiting for Photos here + go.send(()).unwrap(); + let job = import_job(&runner, &imports, paths.clone(), "Trip", false, Some(&activity)).unwrap(); + assert_eq!(job.json()["imported"], 2); + assert!(activity.list().is_empty(), "the row goes when Photos is done"); + let during = seen.lock().unwrap().last().cloned().unwrap(); + assert_eq!(during.len(), 1, "{during:?}"); + assert_eq!((during[0].kind, during[0].label.as_str(), during[0].detail.as_str()), (ACTIVITY_KIND, ACTIVITY_LABEL, "Trip")); + assert_eq!((during[0].cancellable, during[0].total), (false, 0), "no ✕ and no count"); + // on a worker thread: the row stays while Photos works, and goes once the import ends + let job = import_job(&runner, &imports, paths.clone(), "", true, Some(&activity)).unwrap(); + assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); + go.send(()).unwrap(); + ended(&job); + // an export's own import: the export's row covers it + let exported: Vec = paths.iter().map(|p| json!({"path": p})).collect(); + go.send(()).unwrap(); + imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).run(&exported, false); + assert!(seen.lock().unwrap().last().unwrap().is_empty(), "no row of its own while the export's worker waits"); + // ...unless it is left running on its own once the export is done + let started = imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).start(&exported); + assert_eq!(started["running"], true); + assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); + go.send(()).unwrap(); + let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); + ended(&job); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn timeouts_grow_with_the_batch_and_stay_bounded() { + assert_eq!(timeout_for(1), Duration::from_secs(605)); + assert_eq!(timeout_for(100), Duration::from_secs(1100)); + assert_eq!(timeout_for(usize::MAX), Duration::from_secs(7200)); + } +} diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index bc213794..4d7f13ae 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -124,15 +124,14 @@ pub fn specs() -> Vec { /// 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 { 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 { 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 = 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 { + 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 { 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 { "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 `-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 `-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 ), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index e08d0d17..e93d0417 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -78,8 +78,76 @@ fn delete(s: &mut Session, p: &Value) -> Result { 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 { + 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 = match p.get("paths") { + Some(Value::Array(a)) => a + .iter() + .map(|v| v.as_str().map(str::to_string)) + .collect::>() + .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 { + 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::>()})), + 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 { - 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 { 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 } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 263f94c2..2d06d73f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1247,7 +1247,7 @@ pub fn specs() -> Vec { "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 { 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 { "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)?; diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index b83e7275..317a87ac 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -35,11 +35,13 @@ fn read(s: &mut Session, p: &Value) -> Result { 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 { 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 { @@ -84,7 +90,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 1d9f4c66..b7488a02 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -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 { diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index fdfbf550..f356d6e4 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -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, @@ -654,18 +658,29 @@ pub struct ExportOptions { pub remove_location: bool, /// Text watermark (none when absent or the text is empty). pub watermark: Option, - /// 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, /// 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::(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::(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, } +/// 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, +} + +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 { + 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 { + #[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 { + #[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 { + #[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 { + 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(); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index be1a348c..c48aaff7 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -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, Vec, 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, 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, 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 { + 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 { + 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 { + 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 = (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. diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 7c8a3553..20d1983e 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -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, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, } /// 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32761a59..3bc6c431 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// 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, } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1ecbfa20..6f6f0359 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -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) { + 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, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, ) -> (Rendered, Option) { // 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. diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 5257a018..4f44780e 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -104,6 +104,9 @@ pub struct SidecarData { /// `xmp:CreateDate` (first found). Used only when the file itself has no capture time. pub captured: Option, pub develop: Option, + /// 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, } #[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> { + 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)>> { 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 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ee3d1738..6aad89b3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index aaecc0b3..b57bd471 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -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>>>; + + /// 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 = (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 { + args[args.iter().position(|a| a == MARKER).unwrap() + 2..].to_vec() + } + + fn export_two(s: &mut Session, tag: &str) -> Vec { + 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 = 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 = 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 = 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 = 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 = 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")); + } +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 7aff5fd4..18599349 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -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#""#, + ) + .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"); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 57981d92..c768078c 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -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#" + + "# + ); + 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#" + + "#; + 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 = + warnings.iter().cloned().map(serde_json::from_value).collect::>().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 = + reread_wires.iter().cloned().map(serde_json::from_value).collect::>().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::(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 = ["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 = 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] diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs new file mode 100644 index 00000000..11ffec44 --- /dev/null +++ b/crates/engine/tests/preview_read.rs @@ -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="); + 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)); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 1a55be6b..8678b1f7 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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 = 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 { } /// 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, String> { use lightcraft_engine::export::{ExportFormat, ExportOptions}; let format = ExportFormat::parse(ext).unwrap_or(ExportFormat::Png); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index e09c9d4c..9975ef06 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -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::>::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() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d20ae093..93b0567d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "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 { "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 { "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", ], ), ) diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 88e39778..6fbb156e 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -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 { + // 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) diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs index 158019a4..68c319b6 100644 --- a/crates/pipeline/src/tests_hdr.rs +++ b/crates/pipeline/src/tests_hdr.rs @@ -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()); +} diff --git a/crates/raster/src/histogram.rs b/crates/raster/src/histogram.rs index 300fa273..75667bd1 100644 --- a/crates/raster/src/histogram.rs +++ b/crates/raster/src/histogram.rs @@ -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, pub luma: Vec, 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, +} + +/// 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) -> 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, 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 { + 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 { + 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::(); + 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}"); + } + } + } } } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 25571128..41ae75af 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -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(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 139c80d4..8a09a9ed 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -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; diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 8cf518fd..4660b737 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -186,6 +186,63 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { /// 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 { + 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> { + 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) -> Vec { + 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); diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index f5f1b88e..7bcaf9f2 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -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" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 4a639763..10cc7e0e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index f2abad69..3dd94a93 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 86b42e8b..ff672e88 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index bc9d0ed2..65cf9731 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -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" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index afee27aa..f7c469ff 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -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" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index b905a5af..ca286386 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -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}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b5e21e09..9b9ae4c3 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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の写真に追加中" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 15debd72..0b3ebe80 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -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" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 217cde98..9c16f111 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index a7ff2a35..11651aaa 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -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}; каждое объединено в стопку с оригиналом" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 8c227f54..a84297a0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -29,7 +29,7 @@ "Add/Subtract/Intersect Mask": "Добавить/вычесть/пересечь маску", "Added to photos you import that don't already have it.": "Добавляется к импортируемым фото, у которых этого ещё нет.", "Adding": "Добавление", - "Adding Lightroom photos…": "Добавление фото из Lightroom…", + "Adding Lightroom photos…": "Добавление фото Lightroom…", "Advanced search…": "Расширенный поиск…", "After": "После", "After 30 days": "Через 30 дней", @@ -92,7 +92,7 @@ "Auto-Tag from Tracklog": "Автопривязка по треку", "Auto-Tag from Tracklog…": "Автопривязка по треку…", "Automatic": "Автоматически", - "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "«Автоматически» отрисовывает детальный просмотр в показанном размере, но не больше собственных пикселей фото. Выберите размер, чтобы ограничить его: меньше — быстрее.", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Автоматический режим рендерит «Детализацию» в отображаемом размере, вплоть до собственных пикселей фото. Выберите размер, чтобы ограничить: меньше — быстрее.", "Automatically Write Changes into XMP": "Автоматически записывать изменения в XMP", "Automatically write changes into XMP sidecars": "Автоматически записывать изменения в XMP-файлы", "Autumn Gold": "Осеннее золото", @@ -159,7 +159,7 @@ "Catalog Statistics": "Статистика каталога", "Check All": "Отметить все", "Check Output Path": "Проверить путь вывода", - "Check the files of this type": "Отметить файлы этого типа", + "Check the files of this type": "Проверить файлы этого типа", "Choose Another Library…": "Выбрать другую библиотеку…", "Choose Edit Settings to Copy": "Выбрать настройки для копирования", "Choose Edit Settings to Copy…": "Выбрать настройки для копирования…", @@ -180,11 +180,11 @@ "Clear Reference": "Убрать эталон", "Clear all": "Очистить всё", "Clear form": "Очистить форму", - "Clear rating": "Снять оценку", - "Clear rating and advance": "Снять оценку и перейти дальше", + "Clear rating": "Сбросить оценку", + "Clear rating and advance": "Сбросить оценку и перейти дальше", "Cleared History": "История очищена", "Click Zoom Ratio": "Масштаб по щелчку", - "Click a shortcut, then press the new keys (Esc cancels).": "Щёлкните сочетание и нажмите новые клавиши (Esc — отмена).", + "Click a shortcut, then press the new keys (Esc cancels).": "Щёлкните сочетание клавиш, затем нажмите новые (Esc отменяет).", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Щёлкните по тегу, чтобы вставить его. Примеры приведены для IMG_0042.CR3, снятого 14 января 2026 в 05:58:48.", "Click photos in the grid to toggle the keyword; Esc stops": "Щёлкайте по фото в сетке, чтобы переключать ключевое слово; Esc — остановить", "Click the photo to pick a colour · ⇧-click adds more": "Щёлкните по фото, чтобы выбрать цвет · ⇧+щелчок добавляет ещё", @@ -206,7 +206,7 @@ "Color Noise Reduction": "Подавление цветового шума", "Color Priority": "Приоритет цвета", "Color Range": "Цветовой диапазон", - "Color label cleared": "Цветовая метка снята", + "Color label cleared": "Цветовая метка сброшена", "Color space": "Цветовое пространство", "Color space: sRGB (AVIF)": "Цветовое пространство: sRGB (AVIF)", "Combine files…": "Объединить файлы…", @@ -532,8 +532,8 @@ "Image": "Изображение", "Import": "Импорт", "Import Date": "Дата импорта", - "Import Lightroom Catalog": "Импорт каталога Lightroom", - "Import Lightroom Catalog…": "Импорт каталога Lightroom…", + "Import Lightroom Catalog": "Импортировать каталог Lightroom", + "Import Lightroom Catalog…": "Импортировать каталог Lightroom…", "Import Photos": "Импорт фото", "Import Photos…": "Импорт фото…", "Import Point Curve Presets": "Импортировать пресеты точечной кривой", @@ -565,7 +565,7 @@ "Inspect Lightroom Catalog": "Проверить каталог Lightroom", "Inspect Photo": "Просмотр фото", "Inspecting Lightroom Catalog": "Проверка каталога Lightroom", - "Install the SAM 3 Model": "Установка модели SAM 3", + "Install the SAM 3 Model": "Установить модель SAM 3", "Instant ’70s": "Мгновенный стиль 70-х", "Interface": "Интерфейс", "Interface language": "Язык интерфейса", @@ -621,7 +621,7 @@ "LightCraft website": "Сайт LightCraft", "LightCraft — Second Window": "LightCraft — второе окно", "Lightroom Classic Catalog": "Каталог Lightroom Classic", - "Lightroom import cancelled": "Импорт из Lightroom отменён", + "Lightroom import cancelled": "Импорт Lightroom отменён", "Lightroom inspection cancelled": "Проверка Lightroom отменена", "Lights": "Светлые тона", "Limit file size (KB, 0 = off)": "Ограничить размер файла (КБ, 0 = выкл.)", @@ -753,7 +753,7 @@ "No filter presets yet": "Пресетов фильтров пока нет", "No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).", "No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.", - "No matching commands": "Нет подходящих команд", + "No matching commands": "Подходящих команд нет", "No matching photos": "Нет подходящих фото", "No metadata found": "Метаданные не найдены", "No new presets": "Новых пресетов нет", @@ -817,7 +817,7 @@ "Panorama…": "Панорама…", "Parametric curve": "Параметрическая кривая", "Paste": "Вставить", - "Paste Edit Settings": "Вставить настройки правки", + "Paste Edit Settings": "Вставить правки", "Paste Metadata": "Вставить метаданные", "Paste Selected Settings": "Вставить выбранные настройки", "Paste Selected Settings…": "Вставить выбранные настройки…", @@ -898,16 +898,16 @@ "Radius": "Радиус", "Random": "Случайно", "Range": "Диапазон", - "Rate ★": "Оценка ★", - "Rate ★ and advance": "Оценка ★ и перейти дальше", - "Rate ★★": "Оценка ★★", - "Rate ★★ and advance": "Оценка ★★ и перейти дальше", - "Rate ★★★": "Оценка ★★★", - "Rate ★★★ and advance": "Оценка ★★★ и перейти дальше", - "Rate ★★★★": "Оценка ★★★★", - "Rate ★★★★ and advance": "Оценка ★★★★ и перейти дальше", - "Rate ★★★★★": "Оценка ★★★★★", - "Rate ★★★★★ and advance": "Оценка ★★★★★ и перейти дальше", + "Rate ★": "Оценить ★", + "Rate ★ and advance": "Оценить ★ и перейти дальше", + "Rate ★★": "Оценить ★★", + "Rate ★★ and advance": "Оценить ★★ и перейти дальше", + "Rate ★★★": "Оценить ★★★", + "Rate ★★★ and advance": "Оценить ★★★ и перейти дальше", + "Rate ★★★★": "Оценить ★★★★", + "Rate ★★★★ and advance": "Оценить ★★★★ и перейти дальше", + "Rate ★★★★★": "Оценить ★★★★★", + "Rate ★★★★★ and advance": "Оценить ★★★★★ и перейти дальше", "Rating": "Оценка", "Ratings & flags": "Оценки и флаги", "Raw default": "Стандарт RAW", @@ -948,8 +948,8 @@ "Remove (H)": "Удалить (H)", "Remove CA": "Убрать ХА", "Remove Chromatic Aberration": "Убрать хроматические аберрации", - "Camera local tone mapping": "Локальная тональная коррекция камеры", - "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Применить локальную тональную коррекцию, сохранённую в raw (Apple ProRAW), как её рендерит камера", + "Camera local tone mapping": "Локальное тональное отображение камеры", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Применить локальное тональное отображение, сохранённое в RAW (Apple ProRAW), как его отображает камера", "Remove from Album": "Удалить из альбома", "Remove from Favorites": "Удалить из избранного", "Remove from Local": "Убрать из «Локального»", @@ -958,7 +958,7 @@ "Remove from Stack": "Убрать из стопки", "Remove from survey": "Убрать из обзора", "Remove location info": "Удалить сведения о местоположении", - "Remove shortcut": "Удалить сочетание", + "Remove shortcut": "Удалить сочетание клавиш", "Remove this filter": "Убрать этот фильтр", "Remove this group": "Убрать эту группу", "Remove this rule": "Убрать это условие", @@ -998,7 +998,7 @@ "Resize to": "Изменить размер до", "Resolution (ppi)": "Разрешение (ppi)", "Restore": "Восстановить", - "Restore (no shortcut)": "Восстановить (без сочетания)", + "Restore (no shortcut)": "Восстановить (без сочетания клавиш)", "Restore Library from Backup…": "Восстановить библиотеку из резервной копии…", "Restore Version": "Восстановить версию", "Retouch": "Ретушь", @@ -1041,7 +1041,7 @@ "Saving Lightroom import index…": "Сохранение индекса импорта Lightroom…", "Scale": "Масштаб", "Scanning": "Сканирование", - "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по полям «Фокус» и «Лучшие из похожих кадров».", "Screen": "Экран", "Scroll menu down": "Прокрутить меню вниз", "Scroll menu up": "Прокрутить меню вверх", @@ -1113,11 +1113,11 @@ "Show Pins": "Показать булавки", "Show Source": "Показать источник", "Show Thumbnails": "Показывать миниатюры", - "Show commands without a shortcut": "Показывать команды без сочетаний", + "Show commands without a shortcut": "Показать команды без сочетания клавиш", "Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода", "Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея", "Show file names": "Показывать имена файлов", - "Show in Explorer": "Показать в Проводнике", + "Show in Explorer": "Показать в «Проводнике»", "Show in File Manager": "Показать в файловом менеджере", "Show in Finder": "Показать в Finder", "Show on Map": "Показать на карте", @@ -1136,7 +1136,7 @@ "Skip": "Пропустить", "Sky": "Небо", "Slideshow": "Слайд-шоу", - "Slideshow · Space pauses · Esc ends": "Слайд-шоу · Пробел — пауза · Esc — выход", + "Slideshow · Space pauses · Esc ends": "Слайд-шоу · Space — пауза · Esc — завершить", "Smart Album": "Смарт-альбом", "Smart Album Rule Fields": "Поля условий смарт-альбома", "Smart Preview Status": "Состояние смарт-просмотров", @@ -1208,7 +1208,7 @@ "Text": "Текст", "Text direction": "Направление текста", "Texture": "Текстура", - "That key can't be used as a shortcut": "Эту клавишу нельзя использовать в сочетании", + "That key can't be used as a shortcut": "Эту клавишу нельзя использовать как сочетание", "The SAM 3 model is installed: Object and Describe masks are ready.": "Модель SAM 3 установлена: маски «Объект» и «Описать» готовы.", "The download didn't work:": "Загрузка не удалась:", "The original files, unchanged, each with an XMP sidecar holding its edits.": "Исходные файлы без изменений, у каждого — XMP-файл с его правками.", @@ -1216,7 +1216,7 @@ "Theme": "Тема", "They can be restored from Recently Deleted until it is emptied.": "Их можно восстановить из «Недавно удалённых», пока этот раздел не очищен.", "Thirds": "Правило третей", - "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.": "Эта сборка пока не умеет скачивать модель. Чтобы установить её вручную, положите model.safetensors, vocab.json и merges.txt из facebook/sam3 от Meta в папку выше: «Объект» и «Описать» заработают, как только файлы окажутся там.", + "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.": "Эта сборка пока не может загрузить модель. Чтобы установить её вручную, положите model.safetensors, vocab.json и merges.txt из facebook/sam3 от Meta в папку выше: «Объект» и «Описать» заработают сразу после этого.", "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Эта папка недоступна (диск отключён?). Смарт-просмотры не создаются и не используются, пока она не появится снова или вы не выберете другую папку.", "This photo can't be opened": "Это фото не удаётся открыть", "Threshold": "Порог", @@ -1240,7 +1240,7 @@ "Turn on Include subfolders, or pick another folder under Local": "Включите «Включая вложенные папки» или выберите другую папку в «Локальном»", "Type": "Тип", "Uncheck All": "Снять все отметки", - "Uncheck the files of this type": "Снять отметку с файлов этого типа", + "Uncheck the files of this type": "Снять отметки с файлов этого типа", "Undo": "Отменить", "Unedited": "Без правок", "Unflag": "Снять флаг", @@ -1330,99 +1330,99 @@ "saved in": "сохранено в", "this photo": "это фото", "− harder border · + softer border": "− жёстче край · + мягче край", - "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", - "Models": "Models", - "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 Denoise Model": "Модель ИИ-шумоподавления", + "AI Denoise is off: press Use on a model.": "ИИ-шумоподавление выключено: нажмите «Использовать» у модели.", + "AI Denoise needs a model. Install one in Settings.": "Для ИИ-шумоподавления нужна модель. Установите её в настройках.", + "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).": "Его изображение с подавленным шумом ещё не создано, а изображения сами не создаются (Настройки, ИИ-шумоподавление).", + "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": "Создать изображение с подавленным шумом для фото, которые я просматриваю", + "Models": "Модели", + "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.": "Ничего не загружается, пока вы не согласитесь. Модель хранится только на этом компьютере; LightCraft никогда не выгружает и не распространяет её.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Создаются только изображения для фото, у которых значение ИИ-шумоподавления больше 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…": "Настроить ИИ-шумоподавление…", + "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": "Модель шумоподавления установлена и используется: ползунок «Количество ИИ-шумоподавления» в разделе «Детализация» теперь работает", + "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.)": "В этой сборке негде хранить модели шумоподавления, поэтому ИИ-шумоподавление недоступно. (В настольном приложении есть.)", + "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": "делает доступным ИИ-шумоподавление", + "non-commercial use only": "только некоммерческое использование", + "photo has its denoised picture.": "фото с готовым изображением без шума.", + "photo waiting": "фото в очереди", + "photos have their denoised picture.": "фото с готовым изображением без шума.", + "photos waiting": "фото в очереди", + "starts using it for AI Denoise": "начинает использовать её для ИИ-шумоподавления", + "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": "ИИ-шумоподавление включено для запросившего фото", + "After installation, AI Denoise will turn on for the requesting photo.": "После установки ИИ-шумоподавление включится для запросившего фото.", + "Cancel pending action": "Отменить отложенное действие", + "Install a denoise model to enable AI Denoise on this photo": "Установите модель шумоподавления, чтобы включить ИИ-шумоподавление для этого фото", + "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": "Увеличить оценку", + "Increase Rating": "Повысить оценку", "Click to type a value": "Щёлкните, чтобы ввести значение", "Angle": "Угол", "Raw": "RAW", @@ -1430,52 +1430,52 @@ "Copyrighted": "Защищено авторским правом", "Public Domain": "Общественное достояние", "Unknown": "Неизвестно", - "Landscape (wide)": "Альбомная", - "Portrait (tall)": "Книжная", - "Square": "Квадратная", + "Landscape (wide)": "Альбомная (широкая)", + "Portrait (tall)": "Портретная (высокая)", + "Square": "Квадрат", "In Color": "Цветное", "No Label": "Без метки", "Choose an album…": "Выберите альбом…", "No albums yet": "Альбомов пока нет", - "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить альбом.", + "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить этот альбом.", "Some rules need fixing (Edit Smart Album…):": "Некоторые условия нужно исправить (Изменить смарт-альбом…):", - "isn't a field rules know": "неизвестное поле", + "isn't a field rules know": "не является полем, известным условиям", "can't be tested that way": "так проверить нельзя", - "is yes or no": "только «Да» или «Нет»", - "needs a number": "нужно число", - "needs a time like 1/250 or 2": "нужна выдержка вроде 1/250 или 2", - "needs a date like 2026, 2026-08 or 2026-08-14": "нужна дата вроде 2026, 2026-08 или 2026-08-14", - "has the first date after the second": "первая дата позже второй", - "needs two values": "нужны два значения", - "needs a number more than 0": "нужно число больше 0", - "needs hours, days, weeks, months or years": "нужны часы, дни, недели, месяцы или годы", - "isn't one of the choices": "нет среди вариантов", - "needs something to look for (or use “is empty”)": "нужно указать, что искать (или выберите «пусто»)", - "needs something to look for (or use “isn't empty”)": "нужно указать, что искать (или выберите «не пусто»)", - "needs text": "нужен текст", - "can't match any rating from 0 to 5": "не подходит ни к одной оценке от 0 до 5", - "needs an album": "нужен альбом", - "names an album that no longer exists": "указан альбом, которого больше нет", - "can't test a folder; choose an album in it": "нельзя проверять папку; выберите альбом в ней", + "is yes or no": "должно быть «да» или «нет»", + "needs a number": "требует число", + "needs a time like 1/250 or 2": "требует время, например 1/250 или 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "требует дату, например 2026, 2026-08 или 2026-08-14", + "has the first date after the second": "содержит первую дату позже второй", + "needs two values": "требует два значения", + "needs a number more than 0": "требует число больше 0", + "needs hours, days, weeks, months or years": "требует часы, дни, недели, месяцы или годы", + "isn't one of the choices": "не входит в число вариантов", + "needs something to look for (or use “is empty”)": "требует значение для поиска (или используйте «пусто»)", + "needs something to look for (or use “isn't empty”)": "требует значение для поиска (или используйте «не пусто»)", + "needs text": "требует текст", + "can't match any rating from 0 to 5": "не может соответствовать никакой оценке от 0 до 5", + "needs an album": "требует альбом", + "names an album that no longer exists": "указывает на альбом, которого уже нет", + "can't test a folder; choose an album in it": "нельзя проверить папку; выберите в ней альбом", "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её.", - "needs something to look for (or use “are empty”)": "нужно указать, что искать (или выберите «пусты»)", - "needs something to look for (or use “aren't empty”)": "нужно указать, что искать (или выберите «не пусты»)", - "are empty": "пусты", - "aren't empty": "не пусты", + "needs something to look for (or use “are empty”)": "требует значение для поиска (или используйте «пустые»)", + "needs something to look for (or use “aren't empty”)": "требует значение для поиска (или используйте «не пустые»)", + "are empty": "пустые", + "aren't empty": "не пустые", "contains": "содержит", "doesn't contain": "не содержит", "ends with": "заканчивается на", "is": "равно", - "is <": "меньше", - "is >": "больше", + "is <": "<", + "is >": ">", "is after": "после", "is before": "до", "is between": "между", "is empty": "пусто", "is in the last": "за последние", - "is unknown": "неизвестна", - "is ≤": "не больше", - "is ≥": "не меньше", + "is unknown": "неизвестно", + "is ≤": "≤", + "is ≥": "≥", "isn't": "не равно", "isn't empty": "не пусто", "isn't in the last": "не за последние", @@ -1487,43 +1487,43 @@ "Fr": "Пт", "Sa": "Сб", "Su": "Вс", - "Pick a date": "Выбрать дату", - "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы", + "Pick a date": "Выберите дату", + "would make this album include itself through other smart albums": "привело бы к тому, что этот альбом включал бы сам себя через другие смарт-альбомы", "Search albums": "Поиск альбомов", - "No albums match": "Нет подходящих альбомов", - "This is the album you're editing": "Это альбом, который вы изменяете", - "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл", - "Copy Name": "Имя копии", + "No albums match": "Подходящих альбомов нет", + "This is the album you're editing": "Это альбом, который вы редактируете", + "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка зациклилась бы", + "Copy Name": "Название копии", "In a Stack": "В стопке", "File Extension": "Расширение файла", "Video Duration": "Длительность видео", - "Keyword Count": "Число ключевых слов", + "Keyword Count": "Количество ключевых слов", "People Count": "Число людей", "Shutter Speed": "Выдержка", "Cropped": "Кадрировано", "Keywords & People": "Ключевые слова и люди", "Description": "Описание", - "Camera Info": "Сведения о камере", - "Develop": "Обработка", - "Pick Flag": "Флаг", + "Camera Info": "Данные камеры", + "Develop": "Коррекция", + "Pick Flag": "Флаг «Выбрано»", "Any Searchable Text": "Любой текст для поиска", "Filename": "Имя файла", "File Type": "Тип файла", "File Format": "Формат файла", - "Edit Date": "Дата изменения", + "Edit Date": "Изменить дату", "People": "Люди", "Focal Length": "Фокусное расстояние", - "ISO Speed": "ISO", + "ISO Speed": "Светочувствительность ISO", "Has GPS": "Есть GPS", "Has Edits": "Есть правки", - "Focus (assisted culling)": "Резкость (помощь в отборе)", - "Best of Similar Shots": "Лучший из похожих снимков", + "Focus (assisted culling)": "Фокус (помощь при отборе)", + "Best of Similar Shots": "Лучшие из похожих кадров", "Date": "Дата", "Color Label": "Цветовая метка", "Keyword List": "Список ключевых слов", - "Filter Keywords": "Фильтр ключевых слов", + "Filter Keywords": "Фильтровать ключевые слова", "No keywords yet": "Ключевых слов пока нет", - "No keywords match": "Нет подходящих ключевых слов", + "No keywords match": "Подходящих ключевых слов нет", "Keyword Name": "Название ключевого слова", "Synonyms": "Синонимы", "Separated by commas": "Через запятую", @@ -1534,14 +1534,14 @@ "Create Keyword Tag": "Создать ключевое слово", "Edit Keyword Tag": "Изменить ключевое слово", "Create": "Создать", - "No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены.", + "No photo has it; the keywords below it go too.": "Его нет ни на одном фото; вложенные в него ключевые слова тоже исчезнут.", "Edit Keyword Tag…": "Изменить ключевое слово…", - "Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это", - "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", + "Put New Keywords Inside This Keyword": "Добавлять новые ключевые слова внутрь этого ключевого слова", + "Purge Unused Keywords": "Очистить неиспользуемые ключевые слова", "Delete Keyword…": "Удалить ключевое слово…", - "Drop here for the top level": "Отпустите здесь для верхнего уровня", - "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", - "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова.", + "Drop here for the top level": "Перетащите сюда для верхнего уровня", + "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединяются под одним ключевым словом.", + "Select photos to give them keywords.": "Выберите фото, чтобы добавить им ключевые слова.", "Person": "Человек", "Add to All Selected Photos": "Добавить ко всем выбранным фото", "Will Export": "Будет экспортировано", @@ -1555,39 +1555,39 @@ "Save as a new set": "Сохранить как новый набор", "Recent Keywords is built in: choose another name": "«Недавние ключевые слова» — встроенный набор: выберите другое название", "Recent Keywords": "Недавние ключевые слова", - "Import Keywords…": "Импорт ключевых слов…", - "Export Keywords…": "Экспорт ключевых слов…", - "Import Keywords": "Импорт ключевых слов", - "Export Keywords": "Экспорт ключевых слов", + "Import Keywords…": "Импортировать ключевые слова…", + "Export Keywords…": "Экспортировать ключевые слова…", + "Import Keywords": "Импортировать ключевые слова", + "Export Keywords": "Экспортировать ключевые слова", "Keyword Lists": "Списки ключевых слов", "Synchronize Folder…": "Синхронизировать папку…", - "Synchronize Folder": "Синхронизация папки", + "Synchronize Folder": "Синхронизировать папку", "Synchronize": "Синхронизировать", "Scanning…": "Сканирование…", - "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Найти фото, добавленные в эту папку на диске или пропавшие из неё, и файлы XMP, изменённые другими приложениями", - "Bring the library up to date with this folder and the folders inside it:": "Привести медиатеку в соответствие с этой папкой и вложенными папками:", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Найти фото, добавленные в эту папку на диске или отсутствующие в ней, и XMP-файлы, изменённые другими приложениями", + "Bring the library up to date with this folder and the folders inside it:": "Обновить библиотеку по этой папке и вложенным папкам:", "Import new photos": "Импортировать новые фото", - "Remove missing photos from the library": "Убрать отсутствующие фото из медиатеки", - "Read metadata updates from XMP sidecars": "Прочитать изменения метаданных из файлов XMP", - "The library is up to date with this folder.": "Медиатека соответствует этой папке.", - "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые» и могут быть восстановлены; файлы на диске не затрагиваются.", - "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.", - "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", + "Remove missing photos from the library": "Убрать отсутствующие фото из библиотеки", + "Read metadata updates from XMP sidecars": "Прочитать обновлённые метаданные из XMP-файлов", + "The library is up to date with this folder.": "Библиотека соответствует этой папке.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые», их можно восстановить; файлы на диске не затрагиваются.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение XMP-файла заменяет оценку, метки, метаданные и правки фото его содержимым.", + "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в библиотеке в другом месте", "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.", - "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, либо её диск не подключён.", + "Relink photos whose file was renamed or moved": "Повторно связать фото, файл которых переименован или перемещён", "A folder is already being synchronized": "Папка уже синхронизируется", "Synchronizing…": "Синхронизация…", - "Imported": "Импортировано", - "Relinked": "Связано заново", + "Imported": "Импортированные", + "Relinked": "Связано повторно", "Removed": "Убрано", "Metadata read": "Метаданные прочитаны", "Failed": "Ошибки", "Stopped": "Остановлено", "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", "Not saved": "Не сохранено", - "A folder is being synchronized": "Идёт синхронизация папки", - "Wait for the import to finish": "Дождитесь окончания импорта", + "A folder is being synchronized": "Папка синхронизируется", + "Wait for the import to finish": "Дождитесь завершения импорта", "Appearance": "Оформление", "Appearance Mode": "Режим оформления", "Sync with System": "Как в системе", @@ -1601,9 +1601,9 @@ "Midnight": "Полночь", "Silver": "Серебро", "Paper": "Бумага", - "Show less": "Свернуть", + "Show less": "Показать меньше", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", - "Exporting contact sheet": "Экспорт контрольного листа", + "Exporting contact sheet": "Экспорт контактного листа", "Reading folder": "Чтение папки", "Scanning folder": "Сканирование папки", "Building previews": "Создание просмотров", @@ -1662,5 +1662,486 @@ "Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает", "Appearance Settings…": "Настройки оформления…", "Add to favorites": "Добавить в избранное", - "Remove from favorites": "Удалить из избранного" + "Remove from favorites": "Удалить из избранного", + " (library default)": " (по умолчанию для библиотеки)", + " (inverted)": " (инвертировано)", + " · best of its burst": " · лучшее в серии", + " · in a burst": " · в серии", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«сохранено в памяти, но не записано на диск», в верхней панели появляется предупреждение, а LightCraft повторяет попытки, пока", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): DNG проверяется по данным RAW, записывается во временный файл, синхронизируется и перечитывается, прежде чем получит", + "% of all commits": "% всех коммитов", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Справка → Сведения о системе и Настройки → Производительность объясняют, почему GPU не", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(например, D750, D780, D850, D7500, Z 50) больше не отображаются почти чёрными или с задавленными тенями: их уровень чёрного", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(проблема #94): HTTP-запрос с веб-страницы больше не может содержать команду в теле. Длина строк ограничена", + ", picked": ", выбрано", + ", rejected": ", отклонено", + "1 day": "1 день", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 на studio-mac)»), вместо того чтобы позволить обоим писать и незаметно терять правки друг друга. Сбой никогда не оставляет", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "просмотр 1620 × 1080 и импортируются с метаданными: время съёмки, камера, объектив, экспозиция, GPS и XMP. Их данные RAW", + "2026-04 or 2026-04-12": "2026-04 или 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 новых встроенных пресета: портрет, пейзаж, город, еда, времена года, винтаж и тонирование Ч/Б.", + "4 MiB and connections at 16.": "4 МиБ, а число соединений — 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630): в Windows окно и рендеринг на GPU используют только DirectX 12 и не загружают драйвер Vulkan, пока вы", + "= exactly": "= точно", + "A blank template keeps the original names.": "Пустой шаблон сохраняет исходные имена.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Изменение, которое не удаётся сохранить на диск (диск заполнен или отключён), больше не остаётся незамеченным: команда сообщает", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Библиотека открыта только в одной программе одновременно (проблема #99): открытие библиотеки, которую LightCraft или `lightcraft-cli` уже", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Неудачное частичное сохранение (диск заполнен, обрыв сетевого соединения) больше не выглядит как повреждённый каталог", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Засыпающий NAS, оборванное сетевое соединение или раскручивающийся USB-диск больше не замораживают окно (проблема #104):", + "AI Mask Model Status": "Состояние модели ИИ-масок", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "ИИ-маски на основе SAM 3 («Объект» и «Описать» на панели масок): щёлкните объект, чтобы выделить его (⌥+щелчок исключает", + "About": "О программе", + "Active": "Активно", + "Add Face Model…": "Добавить модель лиц…", + "Add Mask": "Добавить маску", + "Add Object Click": "Добавить объект щелчком", + "Add to Local": "Добавить в «Локальное»", + "All the photos imported from this disk are included.": "Включаются все фото, импортированные с этого диска.", + "Analyzing the photo for AI masks…": "Анализ фото для ИИ-масок…", + "Any camera": "Любая камера", + "Any keyword": "Любое ключевое слово", + "Any lens": "Любой объектив", + "Apply": "Применить", + "Auto Select": "Автоматическое выделение", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Автосинхронизация: правки применяются ко всем выбранным фото. Авто-микс Ч/Б. Автоматические версии.", + "Auto settings applied": "Автонастройки применены", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Автопривязка по треку: GPS-координаты для ваших фото из GPX-трека по времени съёмки.", + "Binary assets added": "Добавленные бинарные ресурсы", + "Binary assets removed": "Удалённые бинарные ресурсы", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Браузерная версия (экспериментальная), защита библиотеки (проблема #107): Файл → Резервная копия библиотеки… скачивает каталог", + "CPU threads": "Потоки CPU", + "Camera make and model": "Производитель и модель камеры", + "Cancel AI Mask Model Download": "Отменить загрузку модели ИИ-масок", + "Cancel Face Model Download": "Отменить загрузку модели лиц", + "Cancel Pending Photo Action": "Отменить отложенное действие с фото", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Фото Canon CR2 с EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II и 1D Mark IV (и других", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Файлы Canon CR3 показывают полноразмерный встроенный JPEG (например, 6960 × 4640 на EOS R6 Mark III) вместо", + "Capture date and time in your own format (directives below)": "Дата и время съёмки в вашем формате (директивы ниже)", + "Capture date as YYYYMMDD": "Дата съёмки в формате YYYYMMDD", + "Captured from": "Снято с", + "Captured until": "Снято по", + "Catchlight": "Блик в глазах", + "Change": "Изменить", + "Change Component Mode": "Изменить режим компонента", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Символы, недопустимые в именах файлов (/ \\ : * ? \" < > |), заменяются на -; к существующему имени добавляется -1, -2….", + "Checking names…": "Проверка имён…", + "Choose Face Recognition Model": "Выбрать модель распознавания лиц", + "Choose a model below (Use) to start.": "Выберите модель ниже («Использовать»), чтобы начать.", + "Click the object to select it · ⌥-click leaves a part out": "Щёлкните объект, чтобы выделить его · ⌥+щелчок исключает часть", + "Click to clear these filters": "Щёлкните, чтобы сбросить эти фильтры", + "Click to restore · double-click to rename": "Щёлкните, чтобы восстановить · двойной щелчок переименовывает", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, сохраняет фото и сбрасывает его правки.)", + "Collapse": "Свернуть", + "Collapse Stack": "Свернуть стопку", + "Colors": "Цвета", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Фильтр по цветовым меткам с несколькими метками сразу; раскрывающееся дерево папок в «Локальном».", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Маски по цветовому диапазону: щёлкните по фото, чтобы взять образец. Диапазоны яркости: полоса диапазона, плавность, карта яркости.", + "Commits": "Коммиты", + "Company": "Компания", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Сжатые Nikon NEF (со сжатием без потерь и с потерями, 12- и 14-битные — например, D3200, D5100, D7000, D750, D850, Z 50)", + "Continue": "Продолжить", + "Contributors": "Участники", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Конвертация в DNG, копирование как DNG, слияние фото и смарт-просмотры больше не пишут сразу в конечный файл (проблема", + "Copy Before to After": "Копировать «до» в «после»", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Статус авторских прав, условия использования прав и URL сведений об авторских правах в «Информации», пресетах метаданных и экспорте.", + "Creator metadata": "Метаданные автора", + "Crop, Rotate, Geometry": "Кадрирование, поворот, геометрия", + "Custom template…": "Свой шаблон…", + "Delete Component": "Удалить компонент", + "Delete Point Color": "Удалить точечный цвет", + "Delete Red Eye": "Удалить красные глаза", + "Describe": "Описать", + "Describe what to keep": "Опишите, что оставить", + "Describe what to leave out": "Опишите, что исключить", + "Describe what to select": "Опишите, что выделить", + "Desktop": "Рабочий стол", + "Detect Faces": "Найти лица", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Для поиска лиц (меню «Фото») нужна небольшая однократная загрузка.", + "Display name": "Отображаемое имя", + "Download": "Загрузить", + "Download AI Mask Model": "Загрузить модель ИИ-масок", + "Download Face Model": "Загрузить модель лиц", + "Download the SAM 3 Model?": "Загрузить модель SAM 3?", + "Downloads": "Загрузки", + "Drag Crop Handle": "Перетащить маркер кадрирования", + "Duplicate Component": "Дублировать компонент", + "Edge": "Край", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "настройка «Край», и получают более чёткий результат при увеличении в фоне. Модель работает внутри LightCraft на чистом Rust", + "Edit Label Names": "Изменить названия меток", + "Edit Mask": "Изменить маску", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Править во внешнем редакторе (⇧⌘E): 16-битная TIFF-копия, в стопке, обновляется при возвращении.", + "Edit settings copied": "Настройки правки скопированы", + "Edited or not": "Изменено или нет", + "Editing": "Правка", + "Empty Recently Deleted": "Очистить «Недавно удалённые»", + "Enable Profile Corrections": "Включить коррекцию профиля", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Введите дату в формате YYYY-MM-DD HH:MM:SS", + "Evaluate Face Recognition": "Оценить распознавание лиц", + "Expand": "Развернуть", + "Expand Stack": "Развернуть стопку", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Имена экспортируемых файлов используют теги переименования фото ({title}, {seq:2}, {date:%Y-%m-%d}…), а также новые {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Export stopped unexpectedly": "Экспорт неожиданно остановился", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Экспортируемые файлы пишутся во временный файл, а затем переименовываются на место, поэтому заполненный или отключённый диск", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Экспорт и рендеринг никогда не перезаписывают оригинал фото (проблема #93): экспорт в собственную папку фото с", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Экспорт никогда не получается чёрным из-за GPU (проблема #78): рендеринг на GPU, которому не хватает памяти устройства,", + "Face Boxes": "Рамки лиц", + "Face Model Downloads": "Загрузки моделей лиц", + "Face Models": "Модели лиц", + "Face Recognition On/Off": "Включить/выключить распознавание лиц", + "Face boxes": "Рамки лиц", + "Face detector": "Детектор лиц", + "Face names written to XMP by Lightroom and other apps show up here.": "Имена лиц, записанные в XMP в Lightroom и других приложениях, появляются здесь.", + "Face recognition": "Распознавание лиц", + "Faces": "Лица", + "Faces of a Person": "Лица человека", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Более быстрые экспорт и импорт с карт при той же защите невосстановимых данных (проблема #134): экспорт,", + "Fill": "Заливка", + "First": "Первый", + "First commit": "Первый коммит", + "Five stars": "Пять звёзд", + "Flagged as Pick": "Помечено как выбранное", + "Flagged as Reject": "Помечено как отклонённое", + "Frame time": "Время кадра", + "Frame update": "Обновление кадра", + "GPU rendering": "Рендеринг на GPU", + "Generated demo photo": "Сгенерированное демо-фото", + "Go back to listing them by name": "Вернуться к списку по названию", + "Grab bag": "Случайная подборка", + "Group name": "Название группы", + "HDR Merge Preview": "Предпросмотр слияния HDR", + "HDR Panorama Merge Preview": "Предпросмотр слияния HDR-панорамы", + "HDR Panoramas": "HDR-панорамы", + "HDR panorama": "HDR-панорама", + "HDR panoramas": "HDR-панорамы", + "Home": "Домашняя папка", + "Hours": "Часы", + "ISO speed": "Светочувствительность ISO", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Если библиотеку не удаётся открыть при запуске (она открыта в другой программе, не читается, на неподключённом диске,", + "Import as new": "Импортировать как новое", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Импорт пресетов из других редакторов: XMP-пресеты, классические файлы `.lrtemplate`, «DNG-пресеты» из мобильных приложений и `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Импорт → Копировать проверяет каждую копию, как и «Переместить» (проблема #96): каждый файл записывается как новый, синхронизируется на диск и", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Импорт → Переместить: фото попадают в папку назначения (с теми же папками и переименованием, что и при копировании, например", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Импорт: копирование в любую папку, по дням / по месяцам / в одну папку, переименование при импорте, пресет метаданных, копирование как DNG.", + "Index Faces": "Индексировать лица", + "Index Faces in the Background": "Индексировать лица в фоне", + "Info Overlay Off": "Выключить информационную накладку", + "Info Overlay: Exposure": "Информационная накладка: экспозиция", + "Info Overlay: File": "Информационная накладка: файл", + "Insert at the cursor": "Вставить в позицию курсора", + "Inspect Face Model File": "Проверить файл модели лиц", + "Install Face Model": "Установить модель лиц", + "Invert Component": "Инвертировать компонент", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG с таким же именем (`IMG_0001.CR3` + `IMG_0001.JPG`) больше не используют общий XMP-файл: RAW сохраняет `IMG_0001.xmp`,", + "Join the ArtCraft community on Discord": "Присоединяйтесь к сообществу ArtCraft в Discord", + "Label": "Метка", + "Label Set": "Набор меток", + "Last GPU fallback": "Последний откат с GPU", + "Last commit": "Последний коммит", + "Last loupe render": "Последний рендеринг лупы", + "Lens Blur": "Размытие объектива", + "Library Folders": "Папки библиотеки", + "Library saved": "Библиотека сохранена", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 и старше отказываются его открывать («неподдерживаемый формат … v2»).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft больше не падает при запуске на ПК с Windows с неисправным драйвером Vulkan (проблема #136, например, некоторые Intel UHD", + "Line delta": "Разница строк", + "Linear": "Линейный", + "Lines +/−": "Строки +/−", + "Lines added": "Добавлено строк", + "Lines deleted": "Удалено строк", + "Lock": "Заблокировать", + "Lock Crop Aspect": "Зафиксировать пропорции кадра", + "Locked": "Заблокировано", + "Lossy DNG files and Smart Previews open as raw photos.": "DNG с потерями и смарт-просмотры открываются как RAW-фото.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Стили Luminar: файлы `.lmp` и коллекции `.mplumpack` импортируются как пресеты (группируются по коллекции); ползунки", + "Mask Adjustment": "Коррекция маски", + "Memory budget": "Лимит памяти", + "Merged": "Объединено", + "Merged PRs": "Влитые PR", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Отсутствующие метаданные ({camera}, {title}…) оставляют пустое место; если получившееся имя пустое, сохраняется исходное.", + "Model": "Модель", + "Move Preset to New Group": "Переместить пресет в новую группу", + "Move in Stack": "Переместить в стопке", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Перемещает все фото, импортированные с этого диска, в «Недавно удалённые»; файлы не затрагиваются", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Перемещает фото, импортированные из этой папки, в «Недавно удалённые»; файлы не затрагиваются", + "Name (A–Z)": "Имя (А–Я)", + "Name Face": "Назвать лицо", + "Name Faces": "Назвать лица", + "Name of the folder the original is in": "Название папки с оригиналом", + "Named People": "Люди с именами", + "Nested group": "Вложенная группа", + "New time": "Новое время", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF начинают с цвета и тона, подогнанных под собственный JPEG камеры, как и Sony ARW, вместо приглушённого,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon с разделением на две по-разному сжатые половины пока используют просмотр.)", + "No contributor data was built into this copy.": "В эту копию не встроены данные об участниках.", + "No model credits were built into this copy.": "В эту копию не встроены данные об используемых моделях.", + "No named people in these photos": "На этих фото нет людей с именами", + "No named people yet": "Людей с именами пока нет", + "Not Now": "Не сейчас", + "Object Mask": "Маска объекта", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Маски «Объект» и «Описать» используют SAM 3 — модель сегментации от Meta. Она не входит в LightCraft, а остальные функции работают без неё.", + "October 2026": "Октябрь 2026", + "Only": "Только", + "Original extension without the dot": "Исходное расширение без точки", + "Original file name without its extension": "Исходное имя файла без расширения", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW Panasonic и Leica (RW2, RWL и старые файлы RAW, от DMC-LX1 до DC-S1R II) обрабатываются из данных RAW в", + "Panorama": "Панорама", + "Panorama Merge Preview": "Предпросмотр слияния панорамы", + "Panoramas": "Панорамы", + "Paste Settings": "Вставить настройки", + "Paste from Previous": "Вставить из предыдущего", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "меню «Фото». У плёнки тоже есть контекстное меню фото. Повторное добавление файла из «Недавно удалённых» больше не", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Фото из «Недавно удалённых» можно восстановить в приложении: щёлкните правой кнопкой → Восстановить (или Удалить навсегда), также в", + "Photos per bracket (0 = auto)": "Фото в брекетинге (0 = авто)", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "фото); импорт (в том числе перетаскиванием), поиск отсутствующих фото, создание / удаление смарт-просмотров, предпросмотр переименования,", + "Pictures": "Изображения", + "Prepare AI Masks": "Подготовить ИИ-маски", + "Presets and profiles": "Пресеты и профили", + "Preview only: editing the camera's embedded JPEG": "Только просмотр: правка встроенного JPEG камеры", + "Proof copy created": "Пробная копия создана", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Быстрая коллекция и целевой альбом (B в сетке), наборы ключевых слов (⌥1–⌥9), наборы цветовых меток.", + "Quit with unsaved changes?": "Выйти с несохранёнными изменениями?", + "RAW decoding": "Декодирование RAW", + "Radial": "Радиальный", + "Rating cleared": "Оценка сброшена", + "Real name": "Настоящее имя", + "Recent": "Недавние", + "Recognise faces (experimental)": "Распознавать лица (экспериментально)", + "Recognition models": "Модели распознавания", + "Refine": "Уточнить", + "Refine AI Mask Detail": "Уточнить детали ИИ-маски", + "Refine Edges": "Уточнить края", + "Refine Saturation": "Уточнить насыщенность", + "Refining the mask's detail…": "Уточнение деталей маски…", + "Reliability": "Надёжность", + "Relink Photo": "Повторно связать фото", + "Reload Changed Files": "Перезагрузить изменённые файлы", + "Remove Disk from Library": "Убрать диск из библиотеки", + "Remove Disk from Library…": "Убрать диск из библиотеки…", + "Remove Face Box": "Удалить рамку лица", + "Remove Face Model": "Удалить модель лиц", + "Remove Folder from Library": "Убрать папку из библиотеки", + "Remove a filter above, or choose Clear all": "Уберите фильтр выше или выберите «Очистить всё»", + "Remove from Library…": "Убрать из библиотеки…", + "Remove from Target Album": "Убрать из целевого альбома", + "Remove this face box (undo with Edit ▸ Undo)": "Удалить эту рамку лица (отмена: Правка → Отменить)", + "Rename Component": "Переименовать компонент", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Переименование папки… и перемещение папки в… («Локальное») можно отменить и повторить (проблема #97): папка возвращается на диске вместе с", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Переименование фото никогда не перезаписывает другое фото, когда меняется только регистр букв (проблема #95): на томах с учётом регистра", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Переименование фото сообщает о файлах, которые не удалось вернуть после сбоя (проблема #105), например при обрыве сетевого соединения", + "Reorder Album": "Изменить порядок альбома", + "Reorder Masks": "Изменить порядок масок", + "Resize Face Box": "Изменить размер рамки лица", + "Restore keeps their edits; Import as new deletes the old record first.": "Восстановление сохраняет правки; «Импортировать как новое» сначала удаляет старую запись.", + "Restore them": "Восстановить их", + "Reverse the order": "Обратный порядок", + "Review": "Проверка", + "Rotate Crop": "Поворачивать кадрирование", + "Rules": "Условия", + "Save as Set…": "Сохранить как набор…", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Сохранение метаданных в XMP-файл, записанный другим приложением, больше не заменяет его (проблема #92): LightCraft добавляет свои", + "Scan failed": "Сбой сканирования", + "Search": "Поиск", + "Selecting…": "Выделение…", + "Sequence number zero-padded to N digits (1–9)": "Порядковый номер, дополненный нулями до N цифр (1–9)", + "Sequence number, counting from the start number": "Порядковый номер, отсчитываемый от начального числа", + "Set": "Задать", + "Set Album Cover": "Задать обложку альбома", + "Set Image Zoom and Pan": "Задать масштаб и панорамирование изображения", + "Set Top of Stack": "Сделать верхним в стопке", + "Set date & time": "Задать дату и время", + "Settings file problem": "Проблема с файлом настроек", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Файлы настроек никогда не сбрасываются незаметно (проблема #103): повреждённый `prefs.json`, `presets.json` или `view.json` сохраняется", + "Settings pasted": "Настройки вставлены", + "Shift": "Shift", + "Shift by": "Сдвинуть на", + "Show": "Показать", + "Show Person": "Показать человека", + "Slideshow paused": "Слайд-шоу приостановлено", + "Slideshow resumed": "Слайд-шоу возобновлено", + "Slideshow, second window, All Metadata, System Info.": "Слайд-шоу, второе окно, все метаданные, сведения о системе.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Меньшие и более быстрые каталоги: фото, которые вы только просматривали в «Локальном» (никогда не добавляли, не оценивали и не правили), забываются, если", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Смарт-альбомы с редактором условий: все / любое / ни одно, вложенные группы, 26 полей.", + "Smart preview available": "Смарт-просмотр доступен", + "Smart previews folder changed": "Папка смарт-просмотров изменена", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony ARW примерно до 2017 года (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II и родственные", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4: уменьшенные ARW без потерь теперь декодируют плитки YCbCr с субдискретизацией в линейный RGB,", + "Sort Albums A–Z": "Сортировать альбомы А–Я", + "Sort Albums by Name": "Сортировать альбомы по названию", + "Sort Contents A–Z": "Сортировать содержимое А–Я", + "Star rating (0–5)": "Оценка в звёздах (0–5)", + "Starting": "Запуск", + "Suggest Names for Faces": "Предложить имена для лиц", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Предлагает, кто изображён на фото, по лицам, которым вы уже дали имена. Всё остаётся на вашем компьютере.", + "Swap Before and After": "Поменять «до» и «после» местами", + "System": "Система", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "серии TZ/ZS, Leica D-Lux, V-Lux и C-Lux, а также поколение GH6, GH7, G9 II, S5 II и S9. Они начинают с", + "Table": "Таблица", + "Test Face Model": "Проверить модель лиц", + "That includes the photos in the folders inside it.": "Сюда входят и фото во вложенных папках.", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "У каталога есть версия формата (проблема #102). Открытие библиотеки от более старого LightCraft обновляет её; библиотека,", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Порт управления (`--control`) закрывает соединение, как только получает что-либо, кроме JSON-запроса", + "The number at the end of the original name (IMG_0042 → 0042)": "Число в конце исходного имени (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Исходное расширение добавляется всегда; {ext} лишь вставляет его ещё и внутрь имени.", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Кэш миниатюр считает и удаляет только собственные файлы (проблема #98): библиотека, открытая в папке, где", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Они перемещаются в «Недавно удалённые», их можно восстановить; файлы на диске не затрагиваются.", + "This Computer": "Этот компьютер", + "This build cannot run recognition models: they can be added and chosen, not used.": "Эта сборка не может запускать модели распознавания: их можно добавить и выбрать, но не использовать.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "В этой сборке негде хранить модели лиц, поэтому список моделей недоступен. (В настольном приложении есть.)", + "Time zone": "Часовой пояс", + "Title metadata": "Метаданные заголовка", + "Toggle Mask": "Переключить маску", + "Try Saving Again": "Повторить сохранение", + "Unedited raws": "Неизменённые RAW", + "Unknown tags stay as typed — check the preview for a {typo}.": "Неизвестные теги остаются как введены — проверьте предпросмотр на наличие {typo}.", + "Unnamed Faces": "Лица без имён", + "Updated the edits saved in the external editor": "Правки, сохранённые во внешнем редакторе, обновлены", + "Username": "Имя пользователя", + "Viewing and sharing": "Просмотр и обмен", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Автоимпорт из отслеживаемой папки; конвертация в DNG; дублирование; создание стандартных просмотров / 1:1 / смарт-просмотров.", + "Will be replaced": "Будет заменено", + "Will be restored": "Будет восстановлено", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Выйти без сохранения (проблема #100). Больше не запускается незаметно демо-сеанс, похожий на сброшенную библиотеку, и", + "Your Name": "Ваше имя", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) вместе с XMP-файлами; каждый оригинал покидает карту только после того, как его", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; на томах без учёта регистра смена регистра всё равно выполняется.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "полный диск) больше не считается созданным, и «Создать смарт-просмотры» заменяет его.", + "a literal %": "буквальный символ %", + "a removed photo": "удалённое фото", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "после этого (проблема #101): частичная запись отсекается до повторной попытки LightCraft, поэтому следующий запуск воспроизводит каждое", + "again. Change the period (or turn it off) in Settings → Performance.": "снова. Измените период (или отключите) в Настройки → Производительность.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "по оригиналам библиотеки больше не сканирует всю библиотеку, когда по этому пути ничего нет.", + "all": "все", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "уже есть папка `thumbs/` с другими изображениями, больше не теряет их при усечении или очистке кэша.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "и проверяет файл перед использованием. Маски сохраняют выделение, поэтому рендерятся и экспортируются без модели.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "и каждое импортированное фото одним zip, а Файл → Восстановить библиотеку из резервной копии… возвращает его (текущая библиотека", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "и никогда не замораживает окно. Она необязательна и не входит в LightCraft (лицензия SAM от Meta, около 3,4 ГБ):", + "any": "любое", + "as `.corrupt-