Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5ba5eba7 | ||
|
|
8d79bf082e | ||
|
|
d316d97d80 | ||
|
|
2ab6e973c6 | ||
|
|
c395789026 | ||
|
|
6aa599f869 | ||
|
|
f6f5e6f1b7 | ||
|
|
7094ba1b6e | ||
|
|
cb0e0cb2e5 | ||
|
|
0fb3351c91 | ||
|
|
b9254242a2 | ||
|
|
ab9e8d372c | ||
|
|
b11e5399c6 | ||
|
|
83058f717e | ||
|
|
7962b66a06 | ||
|
|
c140f63372 | ||
|
|
790a341776 | ||
|
|
67b7bc8bc6 | ||
|
|
41e67e1edd | ||
|
|
5d437489b9 | ||
|
|
1e6f0e28e1 | ||
|
|
4af709995f | ||
|
|
9b0d0fd05f | ||
|
|
b1d8ba4284 | ||
|
|
d8efa6a147 | ||
|
|
d4c0a7a3d4 | ||
|
|
68e0827633 | ||
|
|
cad024fbff | ||
|
|
86deb5b004 | ||
|
|
a673b2bb7a | ||
|
|
305ff71474 | ||
|
|
a0e49629d3 | ||
|
|
c8f697a128 | ||
|
|
232cbace63 | ||
|
|
1fa3af8740 | ||
|
|
8e4d7545ff | ||
|
|
1028ed6b88 | ||
|
|
efe7cad73c | ||
|
|
a86defd1b9 | ||
|
|
881bc85dac | ||
|
|
dbe73f0f9c | ||
|
|
0b8c7a237a | ||
|
|
43aa795539 | ||
|
|
38cdd49274 | ||
|
|
0b587a0a8a | ||
|
|
60a98354d9 | ||
|
|
f16f413942 | ||
|
|
48cd9c5e3a | ||
|
|
e2b626b7f7 | ||
|
|
095c7dce87 | ||
|
|
b4a1166f4b | ||
|
|
5413c8bb3a | ||
|
|
07d9678209 | ||
|
|
3c76902bbb | ||
|
|
7cf3719c63 | ||
|
|
d16ab1f238 | ||
|
|
bbcfdf085b | ||
|
|
1e6035d9db | ||
|
|
37cfc46e0d | ||
|
|
b256b8b635 | ||
|
|
e8c817ddea | ||
|
|
d367d83ee6 | ||
|
|
6a05f865ee | ||
|
|
866d02e577 | ||
|
|
4a427894ed |
No files matched your search
Generated
+1
@@ -2892,6 +2892,7 @@ dependencies = [
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-segment",
|
||||
"lightcraft-tiff",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"rayon",
|
||||
|
||||
+12
@@ -191,6 +191,18 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Lightroom Classic catalog migration
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
|
||||
@@ -167,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"# comment\n{}\n{}\n{}\n{}\n",
|
||||
"# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
// (settled: under load a grid shot taken before its thumbnails is darker than the
|
||||
// dimmed export dialog over them, and the comparison below fails)
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot"}),
|
||||
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
|
||||
),
|
||||
)
|
||||
@@ -181,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4);
|
||||
assert_eq!(replies.len(), 6);
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
for (p, dimmed) in [(&a, false), (&b, true)] {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
|
||||
|
||||
@@ -207,6 +207,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
|
||||
@@ -283,6 +283,13 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
@@ -462,6 +469,31 @@ ENVIRONMENT:
|
||||
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
|
||||
";
|
||||
|
||||
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
|
||||
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
|
||||
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
|
||||
launch_imports_in(args, cwd.as_deref(), home.as_deref())
|
||||
}
|
||||
|
||||
fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
|
||||
use lightcraft_engine::import::{LaunchPath, launch_path};
|
||||
let (mut files, mut notices) = (Vec::new(), Vec::new());
|
||||
for a in args {
|
||||
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
|
||||
Some(LaunchPath::Import(p)) => files.push(p),
|
||||
Some(LaunchPath::Refused { path, why }) => {
|
||||
log::warn!("not importing {path} given at launch: {why}");
|
||||
notices
|
||||
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
(files, notices)
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
@@ -534,6 +566,9 @@ fn main() -> eframe::Result {
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
// what the arguments name, resolved now: an empty or relative argument never becomes a drive
|
||||
// root, and a whole drive or home folder is not walked (issue #374)
|
||||
let (files, launch_notices) = launch_imports(&files);
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// the saved language from the start, so a failed start is reported in it too
|
||||
if let Some(ui) = &prefs {
|
||||
@@ -586,6 +621,7 @@ fn main() -> eframe::Result {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
@@ -608,7 +644,12 @@ fn main() -> eframe::Result {
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
@@ -658,6 +699,24 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #374: launch arguments that would walk a whole drive or home folder are reported,
|
||||
/// not imported; empty ones are ignored; the rest are imported as absolute paths.
|
||||
#[test]
|
||||
fn launch_arguments_never_walk_a_drive_or_the_home_folder() {
|
||||
let home = dir("launch");
|
||||
std::fs::create_dir_all(home.join("Trip")).unwrap();
|
||||
let args: Vec<String> = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect();
|
||||
let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home));
|
||||
assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]);
|
||||
assert_eq!(notices.len(), 2, "{notices:?}");
|
||||
assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}");
|
||||
// the working folder is a drive root (a launcher's choice): `.` is never the whole drive
|
||||
let root = home.ancestors().last().unwrap().to_path_buf();
|
||||
let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None);
|
||||
assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}");
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct NativeMenu {
|
||||
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
|
||||
structure: String,
|
||||
text_focus: bool,
|
||||
/// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
|
||||
/// press reaches it.
|
||||
capturing: bool,
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
|
||||
@@ -211,7 +214,7 @@ impl NativeMenu {
|
||||
let _ = tx.send(e.id.0);
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
|
||||
m.rebuild(app);
|
||||
app.native_menu = true;
|
||||
m
|
||||
@@ -281,6 +284,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.menu.init_for_nsapp();
|
||||
self.text_focus = false;
|
||||
self.capturing = false;
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
|
||||
@@ -345,7 +349,8 @@ impl NativeMenu {
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
|
||||
// (none while the keymap editor records a shortcut: every key goes to it)
|
||||
app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
@@ -407,14 +412,16 @@ impl NativeMenu {
|
||||
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
|
||||
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
if focus != self.text_focus {
|
||||
let capturing = app.recording_shortcut.is_some();
|
||||
if focus != self.text_focus || capturing != self.capturing {
|
||||
self.text_focus = focus;
|
||||
self.capturing = capturing;
|
||||
for it in self.items.values() {
|
||||
let Some(sc) = it.shortcut.as_deref() else { continue };
|
||||
if it.accel.is_none() || !yields_to_text(sc) {
|
||||
if it.accel.is_none() {
|
||||
continue;
|
||||
}
|
||||
let accel = if focus { None } else { it.accel };
|
||||
let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
|
||||
let _ = match &it.handle {
|
||||
Handle::Plain(h) => h.set_accelerator(accel),
|
||||
Handle::Check(h) => h.set_accelerator(accel),
|
||||
|
||||
@@ -525,6 +525,17 @@ pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
key_within(&folder_key(path), &folder_key(root))
|
||||
}
|
||||
|
||||
/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
|
||||
/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
|
||||
/// the names keep their case, which the key lowers on Windows.
|
||||
pub fn folder_rest(path: &str, root: &str) -> Option<Vec<String>> {
|
||||
if !key_within(&folder_key(path), &folder_key(root)) {
|
||||
return None;
|
||||
}
|
||||
let depth = split_path(root, false).parts.len();
|
||||
Some(split_path(path, false).parts.into_iter().skip(depth).collect())
|
||||
}
|
||||
|
||||
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
|
||||
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
|
||||
/// separators) is its own identity, so it is read without building a key for it: the check runs
|
||||
|
||||
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
|
||||
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
|
||||
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
|
||||
|
||||
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
|
||||
|
||||
@@ -108,6 +108,57 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
|
||||
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
|
||||
/// type or component count, or when its markers don't run to an end-of-image marker after the
|
||||
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
}
|
||||
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
|
||||
if !matches!(m.sof, 0xC0..=0xC3) {
|
||||
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
|
||||
}
|
||||
if !matches!(m.components, 1 | 3 | 4) {
|
||||
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
|
||||
}
|
||||
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
|
||||
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
|
||||
}
|
||||
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((m.width, m.height, orientation))
|
||||
}
|
||||
|
||||
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
|
||||
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
|
||||
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
|
||||
/// markers that end it.
|
||||
fn reaches_eoi(b: &[u8]) -> bool {
|
||||
let mut p = 2usize;
|
||||
loop {
|
||||
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
|
||||
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
|
||||
p = p.saturating_add(skip);
|
||||
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
|
||||
p = match marker {
|
||||
0xD9 => return true,
|
||||
0xFF => p.saturating_add(1),
|
||||
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
|
||||
_ => {
|
||||
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
|
||||
return false;
|
||||
};
|
||||
if len < 2 {
|
||||
return false;
|
||||
}
|
||||
p.saturating_add(2).saturating_add(len)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
@@ -175,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
|
||||
}
|
||||
|
||||
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
|
||||
/// is most of [`header`]'s time).
|
||||
fn find_ff(b: &[u8]) -> Option<usize> {
|
||||
const ONES: u64 = 0x0101_0101_0101_0101;
|
||||
let (words, tail) = b.as_chunks::<8>();
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
// a zero byte in !w is an 0xFF byte in w
|
||||
let x = !u64::from_ne_bytes(*w);
|
||||
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
|
||||
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
|
||||
}
|
||||
}
|
||||
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
|
||||
}
|
||||
|
||||
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
|
||||
/// fitted with the source aspect ratio.
|
||||
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
|
||||
@@ -297,6 +363,20 @@ mod tests {
|
||||
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ff_matches_a_plain_search() {
|
||||
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
|
||||
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
|
||||
if let Some(i) = at {
|
||||
b[i] = 0xFF;
|
||||
}
|
||||
for start in [0, 1, 5, 9, 290, 299, 300] {
|
||||
let s = &b[start..];
|
||||
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_on_garbage() {
|
||||
assert!(parse_markers(b"").is_none());
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
|
||||
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
|
||||
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
|
||||
//! - [`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
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
@@ -156,6 +158,52 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub format: Format,
|
||||
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
|
||||
pub orientation: u16,
|
||||
}
|
||||
|
||||
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
|
||||
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
|
||||
/// decode.
|
||||
///
|
||||
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
|
||||
/// unsupported layout, compression or frame type) and files whose image data is cut short
|
||||
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
|
||||
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
|
||||
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
|
||||
/// third-party parser is caught on unwinding targets).
|
||||
pub fn read_header(bytes: &[u8]) -> Result<Header> {
|
||||
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
|
||||
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
|
||||
}
|
||||
|
||||
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
|
||||
let (width, height, orientation) = match format {
|
||||
Format::Jpeg => jpeg::header(bytes)?,
|
||||
Format::Png => png_codec::header(bytes)?,
|
||||
Format::Tiff => tiff_codec::header(bytes)?,
|
||||
Format::WebP => webp::header(bytes)?,
|
||||
Format::Psd => psd::header(bytes)?,
|
||||
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
|
||||
_ => {
|
||||
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
|
||||
(d.source_width, d.source_height, d.orientation)
|
||||
}
|
||||
};
|
||||
Ok(Header { format, width, height, orientation })
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
|
||||
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
|
||||
/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
|
||||
/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
|
||||
/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
|
||||
pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
|
||||
if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
|
||||
return Err(err("missing IHDR"));
|
||||
}
|
||||
let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
|
||||
let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
|
||||
let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
|
||||
if w > i32::MAX as u32 || h > i32::MAX as u32 {
|
||||
return Err(err("dimensions out of range"));
|
||||
}
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let depth_ok = match color {
|
||||
0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
|
||||
3 => matches!(depth, 1 | 2 | 4 | 8),
|
||||
2 | 4 | 6 => matches!(depth, 8 | 16),
|
||||
_ => false,
|
||||
};
|
||||
if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
|
||||
return Err(err("invalid IHDR"));
|
||||
}
|
||||
let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
|
||||
while let Some(len) = be32(p) {
|
||||
let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
|
||||
if idat && kind != b"IDAT" {
|
||||
// a chunk follows the image data: the IDAT run is complete
|
||||
after_idat = true;
|
||||
}
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
let start = p.saturating_add(8);
|
||||
let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
|
||||
let Some(data) = data else {
|
||||
if after_idat {
|
||||
break; // a damaged chunk after the image data: a decode ignores it too
|
||||
}
|
||||
return Err(err("truncated"));
|
||||
};
|
||||
match kind {
|
||||
b"PLTE" => palette = true,
|
||||
b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
|
||||
b"IDAT" => {
|
||||
if color == 3 && !palette {
|
||||
return Err(err("indexed image without a palette"));
|
||||
}
|
||||
idat = true;
|
||||
}
|
||||
b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
|
||||
_ => {}
|
||||
}
|
||||
p = start.saturating_add(data.len()).saturating_add(4);
|
||||
}
|
||||
if !idat {
|
||||
return Err(err("no image data"));
|
||||
}
|
||||
if !after_idat {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
if let Some(c) = &info.coding_independent_code_points {
|
||||
let named = match c.color_primaries {
|
||||
|
||||
+265
-72
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (icc, exif, xmp) = resources(res);
|
||||
|
||||
// Layer and mask information: only the layer count sign matters (merged transparency).
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
let lm = r.take(lm_len)?;
|
||||
let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
|
||||
let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
|
||||
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
|
||||
let off = if psb { 8 } else { 4 };
|
||||
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
|
||||
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let plane = width * height * bpc;
|
||||
let row_bytes = width * bpc;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
|
||||
match compression {
|
||||
0 => {
|
||||
for _ in 0..channels {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let counts_len = channels * height;
|
||||
let mut counts = Vec::with_capacity(counts_len);
|
||||
for _ in 0..counts_len {
|
||||
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
for c in 0..channels {
|
||||
let mut out = Vec::with_capacity(plane);
|
||||
for y in 0..height {
|
||||
let src = r.take(counts[c * height + y])?;
|
||||
unpackbits(src, row_bytes, &mut out);
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
|
||||
let (model, n_color) = match mode {
|
||||
1 | 8 => (Model::Gray, 1),
|
||||
2 => (Model::Rgb, 1),
|
||||
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
let alpha = merged_transparency && channels > n_color && mode != 2;
|
||||
// Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
|
||||
// stored after them and never touched.
|
||||
let used = n_color + alpha as usize;
|
||||
let n = width * height;
|
||||
let too_large = || Error::TooLarge(width as u64, height as u64);
|
||||
let n = width.checked_mul(height).ok_or_else(too_large)?;
|
||||
let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(used);
|
||||
match compression {
|
||||
0 => {
|
||||
// Raw planes are borrowed from the input, so their size is bounded by the file.
|
||||
for _ in 0..used {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Row byte-count table: one entry per row per channel (all channels, since the data
|
||||
// follows it). Taken from the input before anything is allocated from it.
|
||||
let entry = if psb { 4 } else { 2 };
|
||||
let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
|
||||
let table = r.take(table_len)?;
|
||||
let count = |i: usize| -> usize {
|
||||
let at = i.saturating_mul(entry);
|
||||
match table.get(at..at.saturating_add(entry)) {
|
||||
Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
|
||||
Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
// PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
|
||||
// at least this many encoded bytes. Requiring it (and the data being present) before
|
||||
// allocating bounds the decoded planes by the file size: a tiny file can no longer
|
||||
// declare gigabytes of zero-padded or repeat-run rows.
|
||||
let min_row = row_bytes.div_ceil(128).saturating_mul(2);
|
||||
let mut data_len = 0usize;
|
||||
for i in 0..used.saturating_mul(height) {
|
||||
let c = count(i);
|
||||
if c < min_row {
|
||||
return Err(err("RLE row too short for its width"));
|
||||
}
|
||||
data_len = data_len.saturating_add(c);
|
||||
}
|
||||
if data_len > bytes.len().saturating_sub(r.p) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
for c in 0..used {
|
||||
let mut out = zeroed::<u8>(plane, too_large)?;
|
||||
for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
|
||||
let src = r.take(count(c * height + y))?;
|
||||
unpackbits(src, row)?;
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
let samples = n.checked_mul(used).ok_or_else(too_large)?;
|
||||
|
||||
// Interleave to device samples.
|
||||
let buf = if mode == 2 {
|
||||
// Indexed → 8-bit RGB via the 768-byte palette.
|
||||
if color_mode_data.len() < 768 || depth != 8 {
|
||||
let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let mut v = Vec::with_capacity(n * 3);
|
||||
for &i in &planes[0] {
|
||||
};
|
||||
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
|
||||
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
|
||||
for (o, &i) in v.chunks_exact_mut(3).zip(idx) {
|
||||
let i = i as usize;
|
||||
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
|
||||
o.copy_from_slice(&[pal[i], pal[256 + i], pal[512 + i]]);
|
||||
}
|
||||
Buf::U8(v)
|
||||
} else {
|
||||
let cmyk = model == Model::Cmyk;
|
||||
match bpc {
|
||||
1 => {
|
||||
let mut v = vec![0u8; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = p[i];
|
||||
let mut v = zeroed::<u8>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
|
||||
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
|
||||
*o = if cmyk && c < 4 { 255 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U8(v)
|
||||
}
|
||||
2 => {
|
||||
let mut v = vec![0u16; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
|
||||
let mut v = zeroed::<u16>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(2)) {
|
||||
let x = u16::from_be_bytes([s[0], s[1]]);
|
||||
*o = if cmyk && c < 4 { 65535 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U16(v)
|
||||
}
|
||||
_ => {
|
||||
let mut v = vec![0f32; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
|
||||
let mut v = zeroed::<f32>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(4)) {
|
||||
let x = f32::from_be_bytes([s[0], s[1], s[2], s[3]]);
|
||||
*o = if cmyk && c < 4 { 1.0 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::F32(v)
|
||||
@@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
|
||||
}
|
||||
|
||||
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
|
||||
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
|
||||
let target = out.len() + row_bytes;
|
||||
while out.len() < target && !src.is_empty() {
|
||||
let h = src[0] as i8;
|
||||
src = &src[1..];
|
||||
if h >= 0 {
|
||||
let n = (h as usize + 1).min(src.len()).min(target - out.len());
|
||||
out.extend_from_slice(&src[..n]);
|
||||
src = &src[(h as usize + 1).min(src.len())..];
|
||||
} else if h != -128 {
|
||||
let n = ((1 - h as isize) as usize).min(target - out.len());
|
||||
if let Some(&b) = src.first() {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
src = &src[1..];
|
||||
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
|
||||
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
|
||||
/// depth, colour mode or compression, or merged image data that runs past the end of the file
|
||||
/// (truncated). The run-length data itself is not unpacked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut r = Rd { b: bytes, p: 0 };
|
||||
if r.take(4)? != b"8BPS" {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let psb = match r.u16()? {
|
||||
1 => false,
|
||||
2 => true,
|
||||
_ => return Err(err("bad version")),
|
||||
};
|
||||
r.take(6)?;
|
||||
let channels = r.u16()? as usize;
|
||||
let height = r.u32()? as usize;
|
||||
let width = r.u32()? as usize;
|
||||
let depth = r.u16()?;
|
||||
let mode = r.u16()?;
|
||||
check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
|
||||
if channels == 0 || channels > 56 {
|
||||
return Err(err("bad channel count"));
|
||||
}
|
||||
let cm_len = r.u32()? as usize;
|
||||
let color_mode_data = r.take(cm_len)?;
|
||||
let res_len = r.u32()? as usize;
|
||||
let (_, exif, _) = resources(r.take(res_len)?);
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
r.take(lm_len)?;
|
||||
let compression = r.u16()?;
|
||||
let bpc = match depth {
|
||||
8 => 1usize,
|
||||
16 => 2,
|
||||
32 => 4,
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let n_color = match mode {
|
||||
1 | 8 | 7 | 2 => 1,
|
||||
3 => 3,
|
||||
4 => 4,
|
||||
9 => return Err(Error::Unsupported(F, "Lab PSD")),
|
||||
_ => return Err(err("unknown colour mode")),
|
||||
};
|
||||
if channels < n_color {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let rest = bytes.len().saturating_sub(r.p);
|
||||
let need = match compression {
|
||||
0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
|
||||
1 => {
|
||||
// the row byte counts, then the rows they count
|
||||
let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
|
||||
let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
|
||||
let mut total = table;
|
||||
for _ in 0..rows {
|
||||
total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
};
|
||||
if need.is_none_or(|n| n > rest) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((width as u32, height as u32, orientation))
|
||||
}
|
||||
|
||||
/// A zero-filled buffer whose allocation failure is an error, not an abort.
|
||||
fn zeroed<T: Clone + Default>(len: usize, too_large: impl Fn() -> Error) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| too_large())?;
|
||||
v.resize(len, T::default());
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
|
||||
/// A run that would write past the end of the row is an error; trailing bytes are ignored.
|
||||
fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
|
||||
let mut at = 0;
|
||||
while at < row.len() {
|
||||
let Some((&h, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
let h = h as i8;
|
||||
if h >= 0 {
|
||||
let n = h as usize + 1;
|
||||
let lit = src.get(..n).unwrap_or(src);
|
||||
let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
|
||||
dst.copy_from_slice(lit);
|
||||
at += lit.len();
|
||||
src = src.get(n..).unwrap_or(&[]);
|
||||
} else if h != -128 {
|
||||
let n = (1 - h as isize) as usize;
|
||||
let Some((&b, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
|
||||
at += n;
|
||||
}
|
||||
}
|
||||
out.resize(target, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
|
||||
@@ -234,11 +349,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn packbits() {
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
|
||||
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0x05, 1], 4, &mut out);
|
||||
assert_eq!(out, vec![1, 0, 0, 0]);
|
||||
let mut out = [0u8; 6];
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
|
||||
assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
// Short run data is zero-padded.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x01, 1], &mut out).unwrap();
|
||||
assert_eq!(out, [1, 0, 0, 0]);
|
||||
// Truncated literal run: copies what is there.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x03, 7, 8], &mut out).unwrap();
|
||||
assert_eq!(out, [7, 8, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packbits_overrun_is_an_error() {
|
||||
// Repeat run of 128 into a 4-byte row.
|
||||
let mut out = [0u8; 4];
|
||||
assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
|
||||
// Literal run of 6 into a 4-byte row.
|
||||
assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
|
||||
}
|
||||
|
||||
/// PSD header + empty colour-mode/resources/layer sections + compression id.
|
||||
fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec<u8> {
|
||||
let mut b = b"8BPS".to_vec();
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&channels.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&depth.to_be_bytes());
|
||||
b.extend_from_slice(&mode.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
|
||||
b.extend_from_slice(&compression.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
|
||||
// 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
|
||||
let mut b = header(56, 1, 1 << 30, 32, 3, 1);
|
||||
b.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
|
||||
// 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
|
||||
let mut b = header(3, 1 << 30, 1, 32, 3, 1);
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
|
||||
// 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
|
||||
let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
|
||||
b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_repeat_run_past_row_width_is_an_error() {
|
||||
// 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
|
||||
let mut b = header(1, 4, 1, 8, 1, 1);
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0x81, 0x55]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_gray16_and_extra_channels_decode() {
|
||||
// 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
|
||||
// plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
|
||||
let mut b = header(2, 2, 2, 16, 1, 1);
|
||||
for _ in 0..4 {
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
|
||||
let d = decode(&b, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (2, 2));
|
||||
let p = d.image.get(1, 1);
|
||||
let q = d.image.get(0, 0);
|
||||
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
@@ -31,23 +28,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
|
||||
let (model, n_color, n_samples, bits) = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if bits < 8 && planar && n_samples > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -111,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
fn limits(opts: &DecodeOptions) -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
|
||||
/// bits per sample).
|
||||
fn layout(ct: ColorType, compression: Option<u32>, planar: bool) -> Result<(Model, usize, usize, u8)> {
|
||||
let l = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if l.3 < 8 && planar && l.2 > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
|
||||
/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
|
||||
/// start past the end of the file (truncated). The compressed data itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
layout(ct, compression, planar)?;
|
||||
// what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
|
||||
if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
|
||||
return Err(Error::Unsupported(F, "compression method"));
|
||||
}
|
||||
let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder<std::io::Cursor<&[u8]>>| {
|
||||
Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
|
||||
};
|
||||
let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
|
||||
.or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
|
||||
.ok_or_else(|| err("no image data"))?;
|
||||
// Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
|
||||
// decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
|
||||
let len = bytes.len() as u64;
|
||||
if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
|
||||
return Err(err("truncated: image data past the end of the file"));
|
||||
}
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
|
||||
Ok((w, h, orientation))
|
||||
}
|
||||
|
||||
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
|
||||
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
|
||||
@@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
|
||||
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
|
||||
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
|
||||
/// itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut p = 12usize;
|
||||
loop {
|
||||
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
|
||||
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
||||
let end = p.saturating_add(8).saturating_add(size);
|
||||
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
|
||||
if end > bytes.len() {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
p = end.saturating_add(size & 1);
|
||||
}
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let exif = d.exif_metadata().ok().flatten();
|
||||
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
@@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) {
|
||||
if let Some(f) = sniff(bytes) {
|
||||
let _ = decode_unguarded(bytes, f, &small_opts());
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
let _ = read_header_unguarded(bytes, f);
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = read_header(bytes);
|
||||
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
|
||||
//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
|
||||
|
||||
use lightcraft_codecs::exif::minimal_exif;
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn pixels(w: usize, h: usize) -> Rgba8 {
|
||||
Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
|
||||
}
|
||||
|
||||
fn meta(exif: &Option<Vec<u8>>) -> EncodeMeta<'_> {
|
||||
EncodeMeta { exif: exif.as_deref(), xmp: Some("<x/>"), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
|
||||
fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut cur = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut cur).unwrap();
|
||||
let mut im = enc.new_image::<colortype::RGB8>(img.width as u32, img.height as u32).unwrap();
|
||||
im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
|
||||
im.write_data(&rgb).unwrap();
|
||||
cur.into_inner()
|
||||
}
|
||||
|
||||
/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
|
||||
fn psd(w: usize, h: usize, orientation: Option<u16>, rle: bool) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"8BPS");
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
b.extend_from_slice(&(w as u32).to_be_bytes());
|
||||
b.extend_from_slice(&8u16.to_be_bytes());
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||||
let mut res = Vec::new();
|
||||
if let Some(o) = orientation {
|
||||
let exif = minimal_exif(o);
|
||||
res.extend_from_slice(b"8BIM");
|
||||
res.extend_from_slice(&1058u16.to_be_bytes());
|
||||
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
|
||||
res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
|
||||
res.extend_from_slice(&exif);
|
||||
if exif.len() % 2 == 1 {
|
||||
res.push(0);
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(&res);
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
|
||||
b.extend_from_slice(&(rle as u16).to_be_bytes());
|
||||
let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
|
||||
if rle {
|
||||
// every row as literal runs of at most 128 bytes (PackBits' longest)
|
||||
let row_len = w + w.div_ceil(128);
|
||||
for _ in 0..3 * h {
|
||||
b.extend_from_slice(&(row_len as u16).to_be_bytes());
|
||||
}
|
||||
for c in 0..3 {
|
||||
let p: Vec<u8> = plane(c).collect();
|
||||
for row in p.chunks(w) {
|
||||
for run in row.chunks(128) {
|
||||
b.push(run.len() as u8 - 1);
|
||||
b.extend_from_slice(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for c in 0..3 {
|
||||
b.extend(plane(c));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
|
||||
fn fixtures(w: usize, h: usize) -> Vec<(String, Vec<u8>)> {
|
||||
let img = pixels(w, h);
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
|
||||
let u16s: Vec<u16> = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
|
||||
let mut out = Vec::new();
|
||||
for o in [None, Some(1), Some(6), Some(8)] {
|
||||
let exif = o.map(minimal_exif);
|
||||
let tag = format!("o{}", o.unwrap_or(0));
|
||||
out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
|
||||
out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
|
||||
out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("psd {tag}"), psd(w, h, o, false)));
|
||||
out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
|
||||
out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
|
||||
}
|
||||
out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
|
||||
out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
|
||||
let mut bmp = Vec::new();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
|
||||
out.push(("bmp".into(), bmp));
|
||||
out
|
||||
}
|
||||
|
||||
/// What the import probe learnt before it read headers: a small decode.
|
||||
fn decoded(bytes: &[u8]) -> Result<Header> {
|
||||
let d = decode(bytes, DecodeOptions::fit(64, 64))?;
|
||||
Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_matches_decode() {
|
||||
for (w, h) in [(160, 96), (96, 160), (7, 5)] {
|
||||
for (name, bytes) in fixtures(w, h) {
|
||||
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
|
||||
assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
|
||||
assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
|
||||
}
|
||||
}
|
||||
let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
|
||||
assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
|
||||
}
|
||||
|
||||
/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
|
||||
/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
|
||||
/// is checked for truncation, which the header readers detect.
|
||||
#[test]
|
||||
fn truncated_files_agree_with_decode() {
|
||||
// 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
let n = bytes.len();
|
||||
let mut cuts: Vec<usize> = (0..100).map(|i| n * i / 100).collect();
|
||||
cuts.extend(n.saturating_sub(64)..n);
|
||||
for cut in cuts {
|
||||
let part = &bytes[..cut];
|
||||
if let Ok(d) = decoded(part) {
|
||||
assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
|
||||
}
|
||||
}
|
||||
for cut in [n / 4, n / 2, n * 9 / 10] {
|
||||
assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_details() {
|
||||
let f = fixtures(160, 96);
|
||||
let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
|
||||
let jpeg = get("jpeg 420 o6");
|
||||
assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
|
||||
let mut trailing = jpeg.clone();
|
||||
trailing.extend_from_slice(&[0; 100]);
|
||||
assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
|
||||
let png = get("png rgba o6");
|
||||
assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
|
||||
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
|
||||
}
|
||||
|
||||
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
|
||||
#[test]
|
||||
fn png_exif_after_idat() {
|
||||
let img = pixels(9, 4);
|
||||
let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
|
||||
let exif = minimal_exif(6);
|
||||
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
|
||||
chunk.extend_from_slice(b"eXIf");
|
||||
chunk.extend_from_slice(&exif);
|
||||
chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
|
||||
let iend = plain.len() - 12;
|
||||
let mut b = plain[..iend].to_vec();
|
||||
b.extend_from_slice(&chunk);
|
||||
b.extend_from_slice(&plain[iend..]);
|
||||
assert_eq!(read_header(&b).unwrap().orientation, 6);
|
||||
}
|
||||
|
||||
fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.push(comps);
|
||||
for c in 0..comps {
|
||||
b.extend_from_slice(&[c + 1, 0x11, 0]);
|
||||
}
|
||||
b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
|
||||
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
|
||||
b.extend_from_slice(&[0xFF, 0xD9]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_chunk(b: &mut Vec<u8>, kind: &[u8; 4], len: u32, data: &[u8]) {
|
||||
b.extend_from_slice(&len.to_be_bytes());
|
||||
b.extend_from_slice(kind);
|
||||
b.extend_from_slice(data);
|
||||
b.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_are_errors() {
|
||||
// a well-formed synthetic JPEG header is read
|
||||
assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
|
||||
let jpeg_bad = [
|
||||
jpeg_segments(0xC0, 0, 21, 3), // zero width
|
||||
jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
|
||||
jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
|
||||
jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
|
||||
jpeg_segments(0xC5, 33, 21, 3), // hierarchical
|
||||
jpeg_segments(0xC0, 33, 21, 2), // two components
|
||||
jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
|
||||
vec![0xFF, 0xD8, 0xFF, 0xD9],
|
||||
];
|
||||
for (i, b) in jpeg_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "JPEG case {i}");
|
||||
}
|
||||
let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
|
||||
no_sos.truncate(2 + 2 + 17);
|
||||
no_sos.extend_from_slice(&[0xFF, 0xD9]);
|
||||
assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
|
||||
|
||||
let mut ok = png_ihdr(5, 3, 8, 2);
|
||||
png_chunk(&mut ok, b"IDAT", 1, &[0]);
|
||||
png_chunk(&mut ok, b"IEND", 0, &[]);
|
||||
assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
|
||||
let png_case = |ihdr: Vec<u8>, chunks: &[(&[u8; 4], u32, &[u8])]| {
|
||||
let mut b = ihdr;
|
||||
for (k, l, d) in chunks {
|
||||
png_chunk(&mut b, k, *l, d);
|
||||
}
|
||||
b
|
||||
};
|
||||
let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
|
||||
let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
|
||||
let png_bad = [
|
||||
png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
|
||||
png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
|
||||
png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
|
||||
png_ihdr(5, 3, 8, 2)[..20].to_vec(),
|
||||
b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in png_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PNG case {i}");
|
||||
}
|
||||
|
||||
let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
|
||||
let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
|
||||
let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
|
||||
// cut short after the IFD
|
||||
assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
|
||||
tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
|
||||
for (i, b) in tiff_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "TIFF case {i}");
|
||||
}
|
||||
// a strip offset pointing past the end of the file
|
||||
let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
|
||||
let n = t(&tiff, ifd) as usize;
|
||||
let mut far = tiff.clone();
|
||||
for e in 0..n {
|
||||
let p = ifd + 2 + e * 12;
|
||||
if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
|
||||
far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
assert_ne!(far, tiff, "the strip offset was found");
|
||||
assert!(read_header(&far).is_err(), "strip past the end");
|
||||
|
||||
let webp_bad = [
|
||||
b"RIFF\0\0\0\0WEBP".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in webp_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "WebP case {i}");
|
||||
}
|
||||
|
||||
let good = psd(4, 3, Some(6), true);
|
||||
assert_eq!(read_header(&good).unwrap().orientation, 6);
|
||||
let set = |pos: usize, v: &[u8]| {
|
||||
let mut b = good.clone();
|
||||
b[pos..pos + v.len()].copy_from_slice(v);
|
||||
b
|
||||
};
|
||||
let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
|
||||
let compression = 34 + res_len + 4;
|
||||
let mut psd_bad = vec![
|
||||
set(12, &0u16.to_be_bytes()), // no channels
|
||||
set(12, &57u16.to_be_bytes()), // too many
|
||||
set(12, &2u16.to_be_bytes()), // too few for RGB
|
||||
set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
|
||||
set(18, &0u32.to_be_bytes()), // zero width
|
||||
set(22, &1u16.to_be_bytes()), // 1-bit
|
||||
set(22, &7u16.to_be_bytes()), // bad depth
|
||||
set(24, &9u16.to_be_bytes()), // Lab
|
||||
set(24, &77u16.to_be_bytes()), // unknown mode
|
||||
set(24, &2u16.to_be_bytes()), // indexed without a palette
|
||||
set(compression, &2u16.to_be_bytes()), // ZIP
|
||||
set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
|
||||
set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
|
||||
];
|
||||
// a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
|
||||
let mut tall = good.clone();
|
||||
tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
|
||||
tall[18..22].copy_from_slice(&1u32.to_be_bytes());
|
||||
psd_bad.push(tall);
|
||||
psd_bad.push(good[..good.len() - 1].to_vec());
|
||||
for (i, b) in psd_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PSD case {i}");
|
||||
}
|
||||
|
||||
for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
|
||||
assert!(read_header(b).is_err(), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
|
||||
/// header agrees with it (deterministic seed).
|
||||
#[test]
|
||||
fn mutated_files_agree_with_decode() {
|
||||
let mut s: u64 = 0x1234_5678_9abc_def1;
|
||||
let mut rnd = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
s
|
||||
};
|
||||
let (mut decodable, mut disagreements) = (0, Vec::new());
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
for _ in 0..300 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..1 + rnd() % 3 {
|
||||
let i = rnd() as usize % b.len();
|
||||
b[i] = rnd() as u8;
|
||||
}
|
||||
if let Ok(d) = decoded(&b) {
|
||||
decodable += 1;
|
||||
let h = read_header(&b);
|
||||
if h.as_ref().ok() != Some(&d) {
|
||||
disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(decodable > 1000, "{decodable}");
|
||||
assert!(disagreements.is_empty(), "{disagreements:#?}");
|
||||
}
|
||||
@@ -40,3 +40,4 @@ sam = ["dep:lightcraft-segment"]
|
||||
|
||||
[dev-dependencies]
|
||||
libc = { workspace = true }
|
||||
lightcraft-tiff = { workspace = true }
|
||||
@@ -1179,15 +1179,17 @@ mod tests {
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
|
||||
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes;
|
||||
/// a poor reference keeps the fallback. Skips cleanly without these CC0 corpus files.
|
||||
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
|
||||
/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
|
||||
/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
|
||||
/// corpus files.
|
||||
#[test]
|
||||
fn corpus_raf_colour_and_white_balance() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", false)] {
|
||||
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
|
||||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
|
||||
|
||||
@@ -268,13 +268,9 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
/// the folder itself.
|
||||
fn follow_library_folder(chosen: &mut Option<String>, from: &str, to: &str) {
|
||||
let Some(c) = chosen.as_deref() else { return };
|
||||
let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from));
|
||||
if fk.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) {
|
||||
let rest = rest.trim_start_matches('/');
|
||||
*chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) });
|
||||
if let Some(rest) = lightcraft_catalog::query::folder_rest(c, from) {
|
||||
// joined by the platform, so the result is spelled like `to` and like a path read from disk
|
||||
*chosen = Some(rest.iter().fold(std::path::PathBuf::from(to), |p, name| p.join(name)).to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1062,6 +1062,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
// ---- import
|
||||
cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None,
|
||||
"{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required",
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
"Import Lightroom Catalog",
|
||||
[],
|
||||
None,
|
||||
"{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?;
|
||||
crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "library.importPreview",
|
||||
"Review Import",
|
||||
|
||||
@@ -41,22 +41,11 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let ep = e.path();
|
||||
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
if ep.is_dir() {
|
||||
stack.push(ep);
|
||||
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
|
||||
&& let Ok(t) = std::fs::read_to_string(&ep)
|
||||
{
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
}
|
||||
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
|
||||
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
|
||||
if let Ok(t) = std::fs::read_to_string(&ep) {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
|
||||
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
|
||||
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
|
||||
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
pub fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
|
||||
return Some(p);
|
||||
}
|
||||
|
||||
+140
-10
@@ -92,8 +92,10 @@ fn ext_upper(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their
|
||||
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed:
|
||||
/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too
|
||||
/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside
|
||||
/// compressed data that is all there: such a file imports and shows as unreadable when rendered).
|
||||
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
|
||||
let m = lightcraft_meta::extract(bytes);
|
||||
@@ -152,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
if !fmt.can_decode() {
|
||||
return Err(format!("{fmt:?} files are not supported yet"));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
|
||||
let o = Orientation::from_exif(d.orientation);
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||||
let o = Orientation::from_exif(header.orientation);
|
||||
let (mut w, mut h) = (header.width, header.height);
|
||||
if o.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
@@ -182,11 +185,15 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error
|
||||
/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX
|
||||
/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed.
|
||||
/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF
|
||||
/// structure): its preview may still be intact. Only a file that is not a raw reports an error.
|
||||
fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, String> {
|
||||
use lightcraft_raw::RawError;
|
||||
match e {
|
||||
lightcraft_raw::RawError::Unsupported(why) => Ok(why),
|
||||
RawError::Unsupported(why) => Ok(why),
|
||||
e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")),
|
||||
e => Err(e.to_string()),
|
||||
RawError::NotRaw => Err(RawError::NotRaw.to_string()),
|
||||
e => Ok(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,10 +336,24 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
|
||||
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
|
||||
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
|
||||
return Some(std::borrow::Cow::Owned(jpeg));
|
||||
}
|
||||
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
|
||||
(undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff))
|
||||
.then_some(std::borrow::Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes) {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
|
||||
@@ -346,7 +367,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
if preview_orientation(bytes, d.orientation).swaps_axes() {
|
||||
@@ -424,6 +445,37 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn image_probe_reads_headers_like_the_decode_did() {
|
||||
let (w, h) = (300u32, 200u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
for o in [None, Some(1), Some(6), Some(3), Some(8)] {
|
||||
let exif = o.map(lightcraft_codecs::exif::minimal_exif);
|
||||
let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap();
|
||||
let png = lightcraft_codecs::encode_png(&img, &meta).unwrap();
|
||||
for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] {
|
||||
let p = probe_bytes(name, bytes).unwrap();
|
||||
// what the probe computed from a decode before
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap();
|
||||
let swap = Orientation::from_exif(d.orientation).swaps_axes();
|
||||
let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) };
|
||||
assert_eq!((p.width, p.height), want, "{name} {o:?}");
|
||||
assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}");
|
||||
assert_eq!(p.kind, MediaKind::Image);
|
||||
assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" });
|
||||
assert_eq!(p.file_size, bytes.len() as u64);
|
||||
assert!(p.content_hash.is_some());
|
||||
assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted");
|
||||
}
|
||||
}
|
||||
assert!(probe_bytes("x.jpg", b"not an image").is_err());
|
||||
}
|
||||
|
||||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||||
/// mostly cancel, edges remain.
|
||||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
@@ -704,6 +756,84 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif
|
||||
/// declares a picture 25x its size.
|
||||
fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] });
|
||||
ifd0.set_child(
|
||||
t::EXIF_IFD,
|
||||
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])),
|
||||
);
|
||||
let mut b = TiffWriter::default().write(&[ifd0]).unwrap();
|
||||
if shell {
|
||||
b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100));
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size
|
||||
/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't
|
||||
/// decode: preview only, with the reason, still showing that image.
|
||||
#[test]
|
||||
fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() {
|
||||
let b = tiff_shell(24, 16, true);
|
||||
let p = probe_bytes("IMG_0001.IIQ", &b).unwrap();
|
||||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16));
|
||||
let why = p.preview_only.expect("marked preview only");
|
||||
assert!(why.contains("600x400") && why.contains("24x16"), "{why}");
|
||||
let (img, src) = load_bytes(&b, 12).unwrap();
|
||||
assert_eq!((img.width, img.height, src.raw), (12, 8, false));
|
||||
assert!(embedded_preview_srgb(&b, 12).is_some());
|
||||
// the same pixels without the private block are a small ordinary image
|
||||
let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap();
|
||||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
|
||||
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
|
||||
}
|
||||
|
||||
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
|
||||
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
|
||||
#[test]
|
||||
fn undecodable_container_with_a_preview_imports_as_preview_only() {
|
||||
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
jpeg[0] = 0x02;
|
||||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||||
f.extend(std::iter::repeat_n(0x11u8, 300));
|
||||
f.extend_from_slice(&jpeg);
|
||||
f.extend(std::iter::repeat_n(0x22u8, 300));
|
||||
let p = probe_bytes("A.MRW", &f).unwrap();
|
||||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30));
|
||||
assert!(p.preview_only.is_some_and(|w| w.contains("Mrw")));
|
||||
let (img, src) = load_bytes(&f, 20).unwrap();
|
||||
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
|
||||
// without a JPEG: a clear error
|
||||
let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err();
|
||||
assert!(e.contains("without an embedded preview"), "{e}");
|
||||
}
|
||||
|
||||
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
|
||||
/// CR3 keeps its own wording.
|
||||
#[test]
|
||||
fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() {
|
||||
use lightcraft_raw::RawError;
|
||||
let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01";
|
||||
assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string()));
|
||||
assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip")));
|
||||
assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok());
|
||||
assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok());
|
||||
assert!(preview_reason(b"x", RawError::NotRaw).is_err());
|
||||
assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 ")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_jobs_show_embedded_previews_then_cached_renders() {
|
||||
use crate::media::QuickSource;
|
||||
|
||||
+393
-73
@@ -33,7 +33,8 @@ use crate::media::ProbeInfo;
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif",
|
||||
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -317,32 +318,96 @@ pub fn is_supported(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
|
||||
/// is never descended into.
|
||||
/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a
|
||||
/// walk that stops at a bound is logged.
|
||||
pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec<String>, top: bool) {
|
||||
let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
|
||||
if (hidden && !top) || skip.is_some_and(|s| p == s) {
|
||||
return;
|
||||
expand_within(paths, skip, crate::walk::Limits::default()).0
|
||||
}
|
||||
|
||||
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
|
||||
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
for p in paths {
|
||||
let p = Path::new(p);
|
||||
if skip.is_some_and(|s| p == s) {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(p) else { return };
|
||||
let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for c in v {
|
||||
walk(&c, skip, out, false);
|
||||
let w = crate::walk::files_in(p, skip, limits, is_supported);
|
||||
if w.truncated {
|
||||
log::warn!(
|
||||
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
|
||||
p.display(),
|
||||
w.entries,
|
||||
limits.max_entries,
|
||||
limits.max_depth
|
||||
);
|
||||
}
|
||||
} else if top || is_supported(p) {
|
||||
truncated |= w.truncated;
|
||||
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
|
||||
} else {
|
||||
// explicitly named files are attempted even with an unknown extension (sniffed)
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
walk(Path::new(p), skip, &mut out, true);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|p| seen.insert(p.clone()));
|
||||
out
|
||||
(out, truncated)
|
||||
}
|
||||
|
||||
/// What a path given on the command line at launch becomes ([`launch_path`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LaunchPath {
|
||||
/// Import this (absolute) file or folder, in the background.
|
||||
Import(String),
|
||||
/// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never
|
||||
/// walked without being chosen in the app (issue #374).
|
||||
Refused { path: String, why: String },
|
||||
}
|
||||
|
||||
/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`);
|
||||
/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is
|
||||
/// resolved against `cwd` — the working folder a launcher picked, often a drive root or
|
||||
/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive
|
||||
/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or
|
||||
/// refused, and so is the home folder `home`: either would walk most of the disk.
|
||||
pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option<PathBuf>) -> Option<LaunchPath> {
|
||||
if arg.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let given = Path::new(arg);
|
||||
let joined = if given.is_absolute() {
|
||||
given.to_path_buf()
|
||||
} else {
|
||||
match cwd {
|
||||
Some(c) => c.join(given),
|
||||
None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }),
|
||||
}
|
||||
};
|
||||
// `.` and `..` resolved by name (no file-system access)
|
||||
let mut abs = PathBuf::new();
|
||||
for c in joined.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
abs.pop();
|
||||
}
|
||||
other => abs.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
let shown = abs.to_string_lossy().to_string();
|
||||
if abs.parent().is_none() {
|
||||
return Some(match dcim(&abs) {
|
||||
Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()),
|
||||
None => LaunchPath::Refused { path: shown, why: "a whole drive".into() },
|
||||
});
|
||||
}
|
||||
let key = lightcraft_catalog::query::folder_key;
|
||||
if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) {
|
||||
return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() });
|
||||
}
|
||||
Some(LaunchPath::Import(shown))
|
||||
}
|
||||
|
||||
/// Probe files (headers + content hash) as an import would.
|
||||
@@ -365,7 +430,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
|
||||
let n = workers(paths.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
@@ -404,18 +469,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
|
||||
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
|
||||
/// byte for byte with the source when there is none); a bad one is removed and the photo
|
||||
/// reported as failed.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the
|
||||
/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and
|
||||
/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte
|
||||
/// with the source when there is none); a bad one is removed and the photo reported as failed.
|
||||
fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
|
||||
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
|
||||
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
@@ -641,6 +704,50 @@ impl Prepared {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Recheck Add-only entries against the owner catalog immediately before commit.
|
||||
/// Preparation may have run while another import added the same path or content.
|
||||
pub fn revalidate_add(&mut self, s: &Session) {
|
||||
let mut paths = HashMap::new();
|
||||
let mut hashes = HashMap::new();
|
||||
for photo in s.catalog.photos() {
|
||||
if let Source::File { path } = &photo.source {
|
||||
paths.insert(normalize_import_path(path), photo.id);
|
||||
}
|
||||
if let Some(hash) = &photo.content_hash {
|
||||
hashes.insert(hash.clone(), photo.id);
|
||||
}
|
||||
}
|
||||
let mut items = Vec::with_capacity(self.items.len());
|
||||
for item in std::mem::take(&mut self.items) {
|
||||
let PreparedItem::Ready(ready) = item else {
|
||||
items.push(item);
|
||||
continue;
|
||||
};
|
||||
let ReadyFile { path, stored, info, sidecar, placed } = *ready;
|
||||
let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied());
|
||||
let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied());
|
||||
if let Some(existing) = existing_path.or(existing_hash) {
|
||||
if let Some(placed) = &placed {
|
||||
crate::import_move::rollback(placed);
|
||||
}
|
||||
items.push(PreparedItem::Duplicate {
|
||||
path,
|
||||
existing: Some(existing),
|
||||
reason: if existing_path.is_some() { "path" } else { "content" },
|
||||
hash: info.content_hash,
|
||||
});
|
||||
} else {
|
||||
items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
}
|
||||
self.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_import_path(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
@@ -699,8 +806,9 @@ impl ImportJob {
|
||||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files — several at once,
|
||||
/// with the names and outcomes of one after another. Nothing in the catalog changes. Stops
|
||||
/// before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
@@ -750,24 +858,39 @@ impl ImportJob {
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
|
||||
// copies or moves — the slow part, reading and writing every byte and verifying it — are
|
||||
// collected and run on several threads (issue #367), then their outcomes take their places.
|
||||
let mut plan = Plan::default();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Failed(path, e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
|
||||
if !opts.local
|
||||
&& let Some(h) = &info.content_hash
|
||||
&& plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h))
|
||||
{
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }));
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
@@ -786,7 +909,6 @@ impl ImportJob {
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
@@ -803,37 +925,35 @@ impl ImportJob {
|
||||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
// the name this file gets depends on what a pending transfer of a like-named file
|
||||
// into the same folder ends up with: that one goes first
|
||||
let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() });
|
||||
let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named));
|
||||
if plan.transfers.iter().any(|t| t.family == family) {
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
How::Place { name: name.unwrap_or(own) }
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
How::Copy { name }
|
||||
};
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG");
|
||||
plan.slots.push(Slot::Transfer);
|
||||
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family });
|
||||
continue;
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
@@ -841,27 +961,199 @@ impl ImportJob {
|
||||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng
|
||||
&& mode == ImportMode::Copy
|
||||
&& stored != path
|
||||
&& info.kind == MediaKind::Raw
|
||||
&& !info.format.eq_ignore_ascii_case("DNG")
|
||||
{
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None }))));
|
||||
}
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the transfers `plan` collected (on several threads) and append its items to `out` in
|
||||
/// file order. Transfers not started because `cancel` was set leave no item.
|
||||
fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) {
|
||||
let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter();
|
||||
for slot in std::mem::take(&mut plan.slots) {
|
||||
let item = match slot {
|
||||
Slot::Item(it) => it,
|
||||
Slot::Transfer => match done.next().flatten() {
|
||||
Some(it) => it,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if let PreparedItem::Ready(r) = &item
|
||||
&& let Some(h) = &r.info.content_hash
|
||||
{
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
out.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
slots: Vec<Slot>,
|
||||
transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
enum Slot {
|
||||
Item(PreparedItem),
|
||||
/// The outcome of the next of [`Plan::transfers`].
|
||||
Transfer,
|
||||
}
|
||||
|
||||
/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]).
|
||||
struct Transfer {
|
||||
path: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
dir: PathBuf,
|
||||
how: How,
|
||||
/// Copy as DNG.
|
||||
dng: bool,
|
||||
/// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's
|
||||
/// names, so they never run at the same time.
|
||||
family: (String, String),
|
||||
}
|
||||
|
||||
enum How {
|
||||
/// A verified copy, under `name` (default: the file's own).
|
||||
Copy { name: Option<String> },
|
||||
/// Move: placed (linked or copied) without touching the source.
|
||||
Place { name: String },
|
||||
}
|
||||
|
||||
/// The names a file called `name` could end up with, as one key: the transfers number a taken name
|
||||
/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`,
|
||||
/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems).
|
||||
fn name_family(name: &str) -> String {
|
||||
let mut stem = match name.rsplit_once('.') {
|
||||
Some((s, _)) if !s.is_empty() => s,
|
||||
_ => name,
|
||||
};
|
||||
while let Some((head, tail)) = stem.rsplit_once('-')
|
||||
&& !head.is_empty()
|
||||
&& !tail.is_empty()
|
||||
&& tail.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
stem = head;
|
||||
}
|
||||
stem.to_lowercase()
|
||||
}
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]):
|
||||
/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few
|
||||
/// enough that each batch soon joins the catalog.
|
||||
pub fn batch_size() -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
workers(usize::MAX).saturating_mul(2).clamp(8, 16)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
8
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one
|
||||
/// not started because `cancel` was set. `done` counts each one finished.
|
||||
fn run_transfers(
|
||||
transfers: Vec<Transfer>,
|
||||
file_bytes: Option<&crate::merge::ByteReader>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
) -> Vec<Option<PreparedItem>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let one = |t: Transfer| {
|
||||
if cancel.load(Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let path = t.path.clone();
|
||||
// last-resort guard: a panic is this file's failure, not the import's
|
||||
let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e));
|
||||
done.fetch_add(1, Relaxed);
|
||||
Some(item)
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = workers(transfers.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<PreparedItem>> = std::iter::repeat_with(|| None).take(transfers.len()).collect();
|
||||
let queue = std::sync::Mutex::new(transfers.into_iter().enumerate());
|
||||
let out = std::sync::Mutex::new(&mut results);
|
||||
// (the tests' injected faults hold for the workers too)
|
||||
#[cfg(test)]
|
||||
let fault = crate::import_move::injected_fault();
|
||||
let work = &|| {
|
||||
#[cfg(test)]
|
||||
crate::import_move::inject(fault);
|
||||
loop {
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some((i, t)) = next else { break };
|
||||
let r = one(t);
|
||||
if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = r;
|
||||
}
|
||||
}
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
let handles: Vec<_> =
|
||||
(0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect();
|
||||
if handles.is_empty() {
|
||||
// no thread to be had: copy here, one after another
|
||||
work();
|
||||
}
|
||||
for h in handles {
|
||||
if h.join().is_err() {
|
||||
log::error!("import: a copy worker stopped unexpectedly");
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
transfers.into_iter().map(one).collect()
|
||||
}
|
||||
|
||||
/// Copy or move one planned file (and Copy as DNG); its item.
|
||||
fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem {
|
||||
let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t;
|
||||
let mut placed = None;
|
||||
let mut stored = match how {
|
||||
How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
};
|
||||
if dng {
|
||||
// decoding the raw twice and encoding it takes several times the file's size in memory
|
||||
let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6);
|
||||
let _permit = crate::memory::work_gate().acquire(weight);
|
||||
match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
@@ -1093,3 +1385,31 @@ impl Session {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revalidate_add_only_converts_matching_ready_item() {
|
||||
let mut s = Session::new();
|
||||
let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg");
|
||||
let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg");
|
||||
let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now");
|
||||
s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap();
|
||||
let ready = |path: &Path| ReadyFile {
|
||||
path: path.to_string_lossy().into(),
|
||||
stored: path.to_string_lossy().into(),
|
||||
info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() },
|
||||
sidecar: None,
|
||||
placed: None,
|
||||
};
|
||||
let mut prepared = Prepared {
|
||||
scanned: 2,
|
||||
items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))],
|
||||
};
|
||||
prepared.revalidate_add(&s);
|
||||
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
|
||||
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ pub(crate) enum Fault {
|
||||
Corrupt,
|
||||
/// Removing the source fails (as on a read-only card).
|
||||
FailRemove,
|
||||
/// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see
|
||||
/// an import's copies run side by side.
|
||||
Slow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) {
|
||||
FAULT.with(|c| c.set(f));
|
||||
}
|
||||
|
||||
/// The calling thread's injected fault (tests only), for the workers it starts to inherit.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn injected_fault() -> Fault {
|
||||
FAULT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// The threads copies made under [`Fault::Slow`] ran on (tests only).
|
||||
#[cfg(test)]
|
||||
static COPY_THREADS: std::sync::Mutex<Vec<std::thread::ThreadId>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn copy_threads() -> Vec<std::thread::ThreadId> {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn injected(f: Fault) -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_pr
|
||||
return Err(io::Error::other("write failed (injected)"));
|
||||
}
|
||||
io::copy(&mut inp, &mut out)?;
|
||||
#[cfg(test)]
|
||||
if injected(Fault::Slow) {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(std::thread::current().id());
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
if injected(Fault::Corrupt) {
|
||||
out.write_all(b"!")?;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
mod lightroom_archive;
|
||||
pub mod lightroom_catalog;
|
||||
pub mod lightroom_job;
|
||||
mod lightroom_sqlite;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
@@ -38,6 +42,7 @@ pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
mod view;
|
||||
pub mod walk;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -186,6 +191,8 @@ pub struct Session {
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
pub library: Option<library::Library>,
|
||||
/// Identity of current library opening; refreshed only after a successful open.
|
||||
pub(crate) library_identity: Arc<()>,
|
||||
/// XMP sidecar preferences (persisted with the library).
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
@@ -274,6 +281,7 @@ impl Session {
|
||||
segmenter: segment::Segmenter::default(),
|
||||
active_spot: None,
|
||||
library: None,
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
|
||||
@@ -412,6 +412,7 @@ impl Session {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -504,12 +505,16 @@ impl Session {
|
||||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
let result = match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Bounded, deduplicated persistence for Lightroom import source data.
|
||||
//!
|
||||
//! Archives use a deterministic JSON representation wrapped in zlib. The JSON
|
||||
//! writer streams through a bounded compressor, so an import cannot first build
|
||||
//! an unbounded archive buffer. Blob columns are omitted by the catalog reader;
|
||||
//! decoded XMP,
|
||||
//! settings, history, snapshots, identities, paths, collections & membership
|
||||
//! remain available for recovery.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miniz_oxide::deflate::{
|
||||
core::{CompressorOxide, create_comp_flags_from_zip_params},
|
||||
stream::deflate,
|
||||
};
|
||||
use miniz_oxide::{MZFlush, MZStatus};
|
||||
use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
|
||||
/// Maximum size of one complete archive on disk, including format header.
|
||||
pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Maximum total size retained in managed archives.
|
||||
pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Maximum number of managed archives retained.
|
||||
pub const MAX_MANAGED_ARCHIVES: usize = 8;
|
||||
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
const HEADER: &[u8] = b"LC-LRARCH\0\x01Z";
|
||||
const NAME_PREFIX: &str = "lightroom-import-";
|
||||
const NAME_SUFFIX: &str = ".lca";
|
||||
const MAX_SCAN: usize = 1024;
|
||||
const IO_BUFFER: usize = 16 * 1024;
|
||||
|
||||
/// Store compact source data below `directory` and return its managed archive.
|
||||
///
|
||||
/// The operation is safe to call before catalog mutation. It rejects a
|
||||
/// symlink directory, writes only `lightroom-import-<number>.lca`, compares the
|
||||
/// complete bounded byte stream with recent managed archives, and retains at
|
||||
/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy
|
||||
/// `lightroom-import-*.json` files are deliberately ignored and preserved for
|
||||
/// an explicit caller warning.
|
||||
pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String> {
|
||||
prepare_directory(directory)?;
|
||||
let mut managed = managed_files(directory)?;
|
||||
managed.sort_by_key(|entry| entry.index);
|
||||
|
||||
let temporary = temporary_path(directory)?;
|
||||
write_archive(&temporary, data).inspect_err(|_| {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
})?;
|
||||
|
||||
let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES);
|
||||
for entry in recent {
|
||||
let same = match same_bounded(&temporary, &entry.path) {
|
||||
Ok(same) => same,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if same {
|
||||
remove_managed_file(&temporary).map_err(|e| e.to_string())?;
|
||||
let matched = entry.path.clone();
|
||||
let mut all = managed.clone();
|
||||
prune_existing(directory, &mut all, Some(&matched))?;
|
||||
return Ok(matched);
|
||||
}
|
||||
}
|
||||
|
||||
let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1));
|
||||
if managed.last().is_some_and(|entry| entry.index == u64::MAX) {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err("managed Lightroom archive index is exhausted".into());
|
||||
}
|
||||
let destination = match next_destination(directory, index) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let size = match fs::metadata(&temporary) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
let new_entry = Managed { index, path: destination.clone(), size };
|
||||
let mut after = managed;
|
||||
after.push(new_entry);
|
||||
after.sort_by_key(|entry| entry.index);
|
||||
let retirees = match retirement_set(&after) {
|
||||
Ok(retirees) => retirees,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let quarantined = match quarantine(&retirees) {
|
||||
Ok(quarantined) => quarantined,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = publish_new(&temporary, &destination) {
|
||||
rollback_quarantine(&quarantined);
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = remove_managed_file(&temporary) {
|
||||
return Err(format!("archive published but temporary cleanup failed: {error}"));
|
||||
}
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("archive published but could not retire {}: {error}", old.display()));
|
||||
}
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
/// Store source data when possible. An archive that reaches its bounded size is
|
||||
/// omitted with a warning so catalog import can still complete.
|
||||
pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<PathBuf>, String> {
|
||||
match store(directory, data) {
|
||||
Ok(path) => Ok(Some(path)),
|
||||
Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Managed {
|
||||
index: u64,
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn prepare_directory(directory: &Path) -> Result<(), String> {
|
||||
match fs::symlink_metadata(directory) {
|
||||
Ok(meta) => {
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir_all(directory).map_err(|e| e.to_string())?;
|
||||
let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn managed_files(directory: &Path) -> Result<Vec<Managed>, String> {
|
||||
let mut files = Vec::new();
|
||||
for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() {
|
||||
if seen >= MAX_SCAN {
|
||||
return Err(format!("too many entries in archive directory (limit {MAX_SCAN})"));
|
||||
}
|
||||
let entry = result.map_err(|e| e.to_string())?;
|
||||
let Some(index) = parse_managed_name(&entry.file_name()) else { continue };
|
||||
let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
files.push(Managed { index, path: entry.path(), size: meta.len() });
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn parse_managed_name(name: &std::ffi::OsStr) -> Option<u64> {
|
||||
let name = name.to_str()?;
|
||||
let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?;
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn temporary_path(directory: &Path) -> Result<PathBuf, String> {
|
||||
let pid = std::process::id();
|
||||
for slot in 0..MAX_SCAN {
|
||||
let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => continue,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe temporary archive name available".into())
|
||||
}
|
||||
|
||||
fn next_destination(directory: &Path, mut index: u64) -> Result<PathBuf, String> {
|
||||
for _ in 0..MAX_SCAN {
|
||||
let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe managed archive name available".into())
|
||||
}
|
||||
|
||||
fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> {
|
||||
lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| {
|
||||
let mut bounded = BoundedWriter { inner: raw, total: 0 };
|
||||
bounded.write_all(HEADER)?;
|
||||
let mut compressor = ZlibWriter::new(&mut bounded);
|
||||
serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?;
|
||||
compressor.finish()?;
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("could not write Lightroom archive: {error}"))
|
||||
}
|
||||
|
||||
fn json_io(error: serde_json::Error) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
|
||||
}
|
||||
|
||||
struct BoundedWriter<'a> {
|
||||
inner: &'a mut dyn Write,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl Write for BoundedWriter<'_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.inner.write_all(bytes)?;
|
||||
self.total = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB")
|
||||
}
|
||||
|
||||
struct ZlibWriter<'a, 'b> {
|
||||
sink: &'a mut BoundedWriter<'b>,
|
||||
compressor: CompressorOxide,
|
||||
plain: u64,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ZlibWriter<'a, 'b> {
|
||||
fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> {
|
||||
let flags = create_comp_flags_from_zip_params(6, 15, 0);
|
||||
ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 }
|
||||
}
|
||||
|
||||
fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> {
|
||||
let mut rest = input;
|
||||
loop {
|
||||
let mut output = [0u8; IO_BUFFER];
|
||||
let result = deflate(&mut self.compressor, rest, &mut output, flush);
|
||||
self.sink.write_all(&output[..result.bytes_written])?;
|
||||
match result.status {
|
||||
Ok(MZStatus::StreamEnd) => return Ok(()),
|
||||
Ok(MZStatus::Ok) => {
|
||||
let consumed = result.bytes_consumed;
|
||||
if consumed > rest.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress"));
|
||||
}
|
||||
rest = &rest[consumed..];
|
||||
if rest.is_empty() && flush == MZFlush::None {
|
||||
return Ok(());
|
||||
}
|
||||
if consumed == 0 && result.bytes_written == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress"));
|
||||
}
|
||||
}
|
||||
Ok(status) => {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}")));
|
||||
}
|
||||
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.run(&[], MZFlush::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for ZlibWriter<'_, '_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.run(bytes, MZFlush::None)?;
|
||||
self.plain = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn same_bounded(left: &Path, right: &Path) -> io::Result<bool> {
|
||||
let left_meta = fs::symlink_metadata(left)?;
|
||||
let right_meta = fs::symlink_metadata(right)?;
|
||||
if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let left_len = left_meta.len();
|
||||
let right_len = right_meta.len();
|
||||
if left_len != right_len || left_len > MAX_ARCHIVE_BYTES {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut left = File::open(left)?;
|
||||
let mut right = File::open(right)?;
|
||||
let mut left_buf = [0u8; IO_BUFFER];
|
||||
let mut right_buf = [0u8; IO_BUFFER];
|
||||
loop {
|
||||
let left_n = left.read(&mut left_buf)?;
|
||||
let right_n = right.read(&mut right_buf)?;
|
||||
if left_n != right_n {
|
||||
return Ok(false);
|
||||
}
|
||||
if left_n == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
if left_buf[..left_n] != right_buf[..right_n] {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retirement_set(entries: &[Managed]) -> Result<Vec<Managed>, String> {
|
||||
retirement_set_protected(entries, None)
|
||||
}
|
||||
|
||||
fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result<Vec<Managed>, String> {
|
||||
let mut ordered = entries.to_vec();
|
||||
ordered.sort_by_key(|entry| entry.index);
|
||||
let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?;
|
||||
let mut retirees = Vec::new();
|
||||
while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES {
|
||||
let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else {
|
||||
return Err("cannot satisfy archive retention limit".into());
|
||||
};
|
||||
let oldest = ordered.remove(position);
|
||||
total = total.saturating_sub(oldest.size);
|
||||
retirees.push(oldest);
|
||||
}
|
||||
Ok(retirees)
|
||||
}
|
||||
|
||||
fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> {
|
||||
let retirees = retirement_set_protected(entries, protected)?;
|
||||
let quarantined = quarantine(&retirees)?;
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quarantine(entries: &[Managed]) -> Result<Vec<(PathBuf, PathBuf)>, String> {
|
||||
let mut moved = Vec::new();
|
||||
let pid = std::process::id();
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&held) {
|
||||
Ok(_) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("retention temporary path already exists: {}", held.display()));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
let meta = fs::symlink_metadata(&entry.path).map_err(|error| {
|
||||
rollback_quarantine(&moved);
|
||||
error.to_string()
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("managed archive changed into non-file: {}", entry.path.display()));
|
||||
}
|
||||
if let Err(error) = fs::rename(&entry.path, &held) {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
moved.push((entry.path.clone(), held));
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) {
|
||||
for (original, held) in moved.iter().rev() {
|
||||
let _ = fs::rename(held, original);
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(temporary, destination) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_managed_file(path: &Path) -> io::Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path"));
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
struct ArchiveRef<'a>(&'a CatalogImport);
|
||||
|
||||
impl Serialize for ArchiveRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?;
|
||||
out.serialize_field("version", &FORMAT_VERSION)?;
|
||||
out.serialize_field("source", &self.0.source)?;
|
||||
out.serialize_field("photos", &PhotoSeq(&self.0.photos))?;
|
||||
out.serialize_field("collections", &RowSeq(&self.0.collections))?;
|
||||
out.serialize_field("members", &RowSeq(&self.0.members))?;
|
||||
out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?;
|
||||
out.serialize_field("warnings", &self.0.warnings)?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoSeq<'a>(&'a [CatalogPhoto]);
|
||||
impl Serialize for PhotoSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for photo in self.0 {
|
||||
out.serialize_element(&PhotoRef(photo))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoRef<'a>(&'a CatalogPhoto);
|
||||
impl Serialize for PhotoRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?;
|
||||
out.serialize_field("source_id", &p.source_id)?;
|
||||
out.serialize_field("uuid", &p.uuid)?;
|
||||
out.serialize_field("path", &p.path)?;
|
||||
out.serialize_field("image", &RowRef(&p.image))?;
|
||||
out.serialize_field("settings", &p.settings)?;
|
||||
out.serialize_field("xmp", &p.xmp)?;
|
||||
out.serialize_field("keywords", &p.keywords)?;
|
||||
out.serialize_field("history", &RowSeq(&p.history))?;
|
||||
out.serialize_field("snapshots", &RowSeq(&p.snapshots))?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowSeq<'a>(&'a [BTreeMap<String, Value>]);
|
||||
impl Serialize for RowSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for row in self.0 {
|
||||
out.serialize_element(&RowRef(row))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowRef<'a>(&'a BTreeMap<String, Value>);
|
||||
impl Serialize for RowRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_map(None)?;
|
||||
for (key, value) in self.0 {
|
||||
out.serialize_entry(key, value)?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn data(source: String) -> CatalogImport {
|
||||
CatalogImport {
|
||||
source,
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rich_data() -> CatalogImport {
|
||||
let photo = CatalogPhoto {
|
||||
source_id: 7,
|
||||
uuid: "uuid-7".into(),
|
||||
path: "/photos/one.raw".into(),
|
||||
image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]),
|
||||
settings: "s = { Exposure2012 = 0.75 }".into(),
|
||||
xmp: "<xmp>decoded</xmp>".into(),
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
photos: vec![photo],
|
||||
collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])],
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_count(path: &Path) -> usize {
|
||||
fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_identical_deterministic_archives() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
let second = store(&path, &input).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(managed_count(&path), 1);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_returns_archive_that_retention_keeps() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
for index in 1..MAX_MANAGED_ARCHIVES {
|
||||
let filler = path.join(format!("lightroom-import-{index}.lca"));
|
||||
File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap();
|
||||
}
|
||||
let reused = store(&path, &input).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
assert!(reused.is_file());
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_managed_archive_count_and_preserves_unrelated_files() {
|
||||
let path = tempdir();
|
||||
let human = path.join("notes.txt");
|
||||
let legacy = path.join("lightroom-import-0.json");
|
||||
fs::write(&human, b"keep").unwrap();
|
||||
fs::write(&legacy, b"legacy").unwrap();
|
||||
for index in 0..(MAX_MANAGED_ARCHIVES + 3) {
|
||||
let input = data(format!("catalog-{index}.lrcat"));
|
||||
store(&path, &input).unwrap();
|
||||
}
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
assert_eq!(fs::read(&human).unwrap(), b"keep");
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"legacy");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize_before_creating_archive() {
|
||||
let path = tempdir();
|
||||
let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1));
|
||||
assert!(store(&path, &input).is_err());
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_catalog_history_is_best_effort_and_does_not_abort_import() {
|
||||
let path = tempdir();
|
||||
let mut input = data("catalog.lrcat".into());
|
||||
input.photos = (0..5_000)
|
||||
.map(|index| CatalogPhoto {
|
||||
source_id: index,
|
||||
uuid: format!("photo-{index}"),
|
||||
path: format!("/photos/{index}.raw"),
|
||||
image: BTreeMap::new(),
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() {
|
||||
let path = tempdir();
|
||||
let archive = store(&path, &rich_data()).unwrap();
|
||||
let bytes = fs::read(archive).unwrap();
|
||||
assert!(bytes.starts_with(HEADER));
|
||||
let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap();
|
||||
let decoded: Value = serde_json::from_slice(&json).unwrap();
|
||||
assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION));
|
||||
assert_eq!(decoded["source"], serde_json::json!("source.lrcat"));
|
||||
assert_eq!(decoded["photos"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7));
|
||||
assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7"));
|
||||
assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw"));
|
||||
assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }"));
|
||||
assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("<xmp>decoded</xmp>"));
|
||||
assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"]));
|
||||
assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5));
|
||||
assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4]));
|
||||
assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255]));
|
||||
assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers"));
|
||||
assert_eq!(decoded["members"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["members"][0]["image"], serde_json::json!(7));
|
||||
assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["warnings"], serde_json::json!(["one warning"]));
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() {
|
||||
let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64)
|
||||
.map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 })
|
||||
.collect();
|
||||
entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 });
|
||||
let retirees = retirement_set(&entries).unwrap();
|
||||
assert_eq!(retirees.len(), 3);
|
||||
assert_eq!(retirees.iter().map(|entry| entry.index).collect::<Vec<_>>(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_failure_preserves_old_archive() {
|
||||
let path = tempdir();
|
||||
let old = path.join("lightroom-import-0.lca");
|
||||
let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id()));
|
||||
fs::write(&old, b"old archive").unwrap();
|
||||
fs::write(&held, b"occupied").unwrap();
|
||||
let entry = Managed { index: 0, path: old.clone(), size: 11 };
|
||||
assert!(quarantine(&[entry]).is_err());
|
||||
assert_eq!(fs::read(&old).unwrap(), b"old archive");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,450 @@
|
||||
//! Detached Lightroom catalog preparation and owner-thread commit.
|
||||
//!
|
||||
//! `LightroomJob::new` takes only an in-memory session snapshot. `prepare` owns all catalog
|
||||
//! reading, probing and archive staging, while `commit_prepared` only applies catalog operations
|
||||
//! to the session that created the job. Archive index bytes are returned for a worker to finish
|
||||
//! after the owner has accepted the catalog operation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::import::{ImportJob, ImportMode, ImportOptions, Prepared};
|
||||
use crate::lightroom_catalog::{self, CatalogImport, ImportIndex};
|
||||
|
||||
const COMMAND: &str = "library.importLightroom";
|
||||
const MAX_INDEX_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn error(message: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
|
||||
}
|
||||
|
||||
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
|
||||
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
|
||||
if archive_dir.is_some() && archive_path.is_none() {
|
||||
data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into());
|
||||
}
|
||||
Ok(archive_path)
|
||||
}
|
||||
|
||||
/// Memory identity of the library which owns a prepared completion.
|
||||
///
|
||||
/// Each successful open/close refreshes session identity, including reopening the same path.
|
||||
/// The owner check deliberately does not compare catalog revisions: ordinary edits while preparation
|
||||
/// runs must survive and are merged by the owner commit.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LightroomLibraryToken {
|
||||
library_dir: Option<PathBuf>,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
|
||||
impl LightroomLibraryToken {
|
||||
fn capture(s: &crate::Session) -> Self {
|
||||
Self {
|
||||
library_dir: s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()),
|
||||
identity: Arc::clone(&s.library_identity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` still refers to the library which started preparation.
|
||||
pub fn matches(&self, s: &crate::Session) -> bool {
|
||||
Arc::ptr_eq(&self.identity, &s.library_identity)
|
||||
&& self.library_dir == s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone())
|
||||
}
|
||||
|
||||
pub fn library_dir(&self) -> Option<&Path> {
|
||||
self.library_dir.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-free result of catalog preparation.
|
||||
pub struct PreparedLightroom {
|
||||
pub(crate) data: CatalogImport,
|
||||
pub(crate) normal: Prepared,
|
||||
pub(crate) index: ImportIndex,
|
||||
pub(crate) archive_path: Option<PathBuf>,
|
||||
pub(crate) token: LightroomLibraryToken,
|
||||
pub(crate) missing_sources: HashSet<String>,
|
||||
pub(crate) update_existing: bool,
|
||||
pub(crate) now: String,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
impl PreparedLightroom {
|
||||
pub fn source(&self) -> &str {
|
||||
&self.data.source
|
||||
}
|
||||
|
||||
pub fn photo_count(&self) -> usize {
|
||||
self.data.photos.len()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled
|
||||
}
|
||||
|
||||
/// Roll back any normal-import filesystem placement held by this prepared value.
|
||||
pub fn rollback(&self) {
|
||||
self.normal.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
/// Index write returned by owner commit. `finish` is intended for a worker after the owner has
|
||||
/// returned, and performs the only archive-index filesystem operation in this pipeline.
|
||||
pub struct LightroomArchiveFinalization {
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl LightroomArchiveFinalization {
|
||||
pub fn finish(self) -> crate::Result<()> {
|
||||
let (Some(path), Some(bytes)) = (self.path, self.bytes) else { return Ok(()) };
|
||||
if bytes.len() > MAX_INDEX_BYTES {
|
||||
return Err(error("Lightroom import index exceeds 16 MiB"));
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner-thread catalog result. The report is ready for the UI immediately; index finalization
|
||||
/// can remain on the worker while its task stays alive.
|
||||
pub struct LightroomCompletion {
|
||||
pub report: Value,
|
||||
pub finalization: LightroomArchiveFinalization,
|
||||
}
|
||||
|
||||
/// Alias used by hosts that call the result a commit.
|
||||
pub type LightroomCommit = LightroomCompletion;
|
||||
|
||||
/// A Lightroom import detached from the owner thread.
|
||||
pub struct LightroomJob {
|
||||
path: PathBuf,
|
||||
update_existing: bool,
|
||||
import: ImportJob,
|
||||
token: LightroomLibraryToken,
|
||||
archive_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl LightroomJob {
|
||||
/// Snapshot session state only. This constructor performs no filesystem work.
|
||||
pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result<Self> {
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let import = ImportJob::new(s, opts)?;
|
||||
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.import.total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.import.done.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(self.done(), self.total())
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.total)
|
||||
}
|
||||
|
||||
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.done)
|
||||
}
|
||||
|
||||
/// Finish an owner commit's deferred archive-index write on a worker.
|
||||
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
|
||||
finalization.finish()
|
||||
}
|
||||
|
||||
/// Read/probe/stage all source data off the owner thread.
|
||||
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.total.store(1, Ordering::Relaxed);
|
||||
self.import.done.store(0, Ordering::Relaxed);
|
||||
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
|
||||
lightroom_catalog::validate_for_job(&data).map_err(error)?;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.done.fetch_add(1, Ordering::Relaxed);
|
||||
let paths: Vec<String> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
|
||||
.map(|photo| photo.path.clone())
|
||||
.collect();
|
||||
let missing_sources = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
|
||||
.filter(|photo| !Path::new(&photo.path).is_file())
|
||||
.map(|photo| normalize(&photo.path))
|
||||
.collect();
|
||||
let normal = self.import.prepare(&paths, cancel);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
|
||||
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
|
||||
let index = lightroom_catalog::load_index(index_path.as_deref())?;
|
||||
Ok(PreparedLightroom {
|
||||
data,
|
||||
normal,
|
||||
index,
|
||||
archive_path,
|
||||
token: self.token.clone(),
|
||||
missing_sources,
|
||||
update_existing: self.update_existing,
|
||||
now: self.import.now().to_string(),
|
||||
cancelled: cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply prepared data on the owner thread. No source, archive or index filesystem work occurs.
|
||||
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
|
||||
if prepared.cancelled {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
if !prepared.token.matches(s) {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import belongs to a different open library"));
|
||||
}
|
||||
// A command-scoped Lightroom import must never rewrite source XMP sidecars. Direct depth-0
|
||||
// callers do not set this transient flag, so they cannot affect a later command.
|
||||
if s.depth > 0 {
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
|
||||
let undo0 = s.undo.len();
|
||||
prepared.normal.revalidate_add(s);
|
||||
let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let now = prepared.now.clone();
|
||||
let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?;
|
||||
let applied = lightroom_catalog::apply_prepared(
|
||||
s,
|
||||
prepared.data,
|
||||
lightroom_catalog::ApplyPrepared {
|
||||
update_existing: prepared.update_existing,
|
||||
files,
|
||||
index: &mut prepared.index,
|
||||
archive_path: prepared.archive_path,
|
||||
missing_sources: &prepared.missing_sources,
|
||||
undo0,
|
||||
},
|
||||
)?;
|
||||
let path = prepared.token.library_dir().map(|dir| dir.join("Interop/lightroom-index.json"));
|
||||
let bytes = if path.is_some() { Some(serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?) } else { None };
|
||||
Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } })
|
||||
}
|
||||
|
||||
fn normalize(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_data() -> CatalogImport {
|
||||
CatalogImport {
|
||||
source: "fixture.lrcat".into(),
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled_prepared(s: &crate::Session) -> PreparedLightroom {
|
||||
PreparedLightroom {
|
||||
data: empty_data(),
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path: None,
|
||||
token: LightroomLibraryToken::capture(s),
|
||||
missing_sources: HashSet::new(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepared_for(s: &crate::Session, path: &str, rating: i64) -> PreparedLightroom {
|
||||
let mut image = std::collections::BTreeMap::new();
|
||||
image.insert("id_local".into(), serde_json::json!(1));
|
||||
image.insert("rating".into(), serde_json::json!(rating));
|
||||
image.insert("pick".into(), serde_json::json!(0));
|
||||
image.insert("fileFormat".into(), serde_json::json!("JPG"));
|
||||
image.insert("fileWidth".into(), serde_json::json!(1));
|
||||
image.insert("fileHeight".into(), serde_json::json!(1));
|
||||
PreparedLightroom {
|
||||
data: CatalogImport {
|
||||
source: "fixture.lrcat".into(),
|
||||
photos: vec![lightroom_catalog::CatalogPhoto {
|
||||
source_id: 1,
|
||||
uuid: "fixture-photo".into(),
|
||||
path: path.into(),
|
||||
image,
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path: None,
|
||||
token: LightroomLibraryToken::capture(s),
|
||||
missing_sources: [normalize(path)].into_iter().collect(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
|
||||
let mut session = crate::Session::new();
|
||||
let before = session.catalog.to_snapshot();
|
||||
let prepared = cancelled_prepared(&session);
|
||||
assert!(commit_prepared(&mut session, prepared).is_err());
|
||||
assert_eq!(session.catalog.to_snapshot(), before);
|
||||
assert!(session.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_identity_rejects_late_completion_after_reopen() {
|
||||
let mut session = crate::Session::new();
|
||||
let token = LightroomLibraryToken::capture(&session);
|
||||
assert!(token.matches(&session));
|
||||
session.library_identity = Arc::new(());
|
||||
assert!(!token.matches(&session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_reimport_preserves_current_edit_and_uses_one_undo_per_import() {
|
||||
let mut session = crate::Session::new();
|
||||
let path = "/missing/lightroom-job-edit.jpg";
|
||||
let prepared = prepared_for(&session, path, 4);
|
||||
let first = commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1));
|
||||
assert_eq!(session.undo.len(), 1);
|
||||
let id = lightcraft_catalog::PhotoId(1);
|
||||
let mut personal = lightcraft_develop::DevelopSettings::default();
|
||||
personal.light.exposure = 1.5;
|
||||
session.set_develop(id, personal, "Personal").unwrap();
|
||||
let before = session.catalog.photo(id).map(|photo| photo.develop.clone());
|
||||
let prepared = prepared_for(&session, path, 5);
|
||||
commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(session.catalog.photo(id).map(|photo| photo.develop.clone()), before);
|
||||
assert_eq!(session.catalog.photo(id).map(|photo| photo.rating), Some(5));
|
||||
assert_eq!(session.undo.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_history_archive_keeps_prepared_import_successful_with_warning() {
|
||||
let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id()));
|
||||
let mut data = empty_data();
|
||||
data.photos = (0..5_000)
|
||||
.map(|source_id| {
|
||||
let image = std::collections::BTreeMap::from([
|
||||
("id_local".into(), serde_json::json!(source_id)),
|
||||
("fileFormat".into(), serde_json::json!("JPG")),
|
||||
("fileWidth".into(), serde_json::json!(1)),
|
||||
("fileHeight".into(), serde_json::json!(1)),
|
||||
]);
|
||||
lightroom_catalog::CatalogPhoto {
|
||||
source_id,
|
||||
uuid: format!("scale-{source_id}"),
|
||||
path: format!("/missing/scale-{source_id}.jpg"),
|
||||
image,
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![std::collections::BTreeMap::from([(
|
||||
"text".into(),
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let archive_path = stage_archive(Some(&archive_dir), &mut data).unwrap();
|
||||
assert!(archive_path.is_none());
|
||||
let mut session = crate::Session::new();
|
||||
let token = LightroomLibraryToken::capture(&session);
|
||||
let prepared = PreparedLightroom {
|
||||
data,
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path,
|
||||
token,
|
||||
missing_sources: HashSet::new(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: false,
|
||||
};
|
||||
let completion = commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(completion.report["photos"], 5_000);
|
||||
assert!(
|
||||
completion.report["warnings"]
|
||||
.as_array()
|
||||
.is_some_and(|warnings| warnings.iter().any(|warning| warning == "Lightroom source archive exceeded 32 MiB; archive skipped"))
|
||||
);
|
||||
assert_eq!(session.catalog.len(), 5_000);
|
||||
std::fs::remove_dir_all(archive_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_fn_import_does_not_rewrite_source_sidecar() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id()));
|
||||
let path = dir.join("source.jpg");
|
||||
let sidecar = dir.join("source.xmp");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"fixture").unwrap();
|
||||
std::fs::write(&sidecar, b"original sidecar").unwrap();
|
||||
let original = std::fs::read(&sidecar).unwrap();
|
||||
let mut session = crate::Session::new();
|
||||
session.xmp.auto_write = true;
|
||||
let result = session.execute_fn("library.importLightroom", |s| {
|
||||
let prepared = prepared_for(s, &path.to_string_lossy(), 5);
|
||||
let completion = commit_prepared(s, prepared)?;
|
||||
Ok(completion.report)
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), original);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 13;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 14;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -100,6 +100,7 @@ impl Lua {
|
||||
struct LuaParser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl LuaParser<'_> {
|
||||
@@ -210,6 +211,15 @@ impl LuaParser<'_> {
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
||||
}
|
||||
fn value(&mut self) -> Result<Lua, String> {
|
||||
if self.depth >= 64 {
|
||||
return self.err("Lua settings exceed nesting limit");
|
||||
}
|
||||
self.depth += 1;
|
||||
let result = self.value_inner();
|
||||
self.depth -= 1;
|
||||
result
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Lua, String> {
|
||||
self.skip_ws();
|
||||
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
||||
match c {
|
||||
@@ -315,7 +325,10 @@ impl LuaParser<'_> {
|
||||
|
||||
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
||||
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
|
||||
if text.len() > 16 << 20 {
|
||||
return Err("Lua settings exceed 16 MiB".into());
|
||||
}
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 };
|
||||
p.skip_ws();
|
||||
let save = p.i;
|
||||
match p.ident().as_deref() {
|
||||
@@ -347,6 +360,12 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
|
||||
let root = parse_lua(text)?;
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
||||
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
||||
let (props, values) = lua_settings_props(settings)?;
|
||||
Ok((title, props, values))
|
||||
}
|
||||
|
||||
/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua.
|
||||
pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> {
|
||||
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
||||
let mut props = Props::new();
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
@@ -374,7 +393,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
|
||||
};
|
||||
props.insert(key, vals);
|
||||
}
|
||||
Ok((title, props, values))
|
||||
Ok((props, values))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- XMP in files
|
||||
|
||||
@@ -83,28 +83,22 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`).
|
||||
/// Expand files/folders (recursively, bounded: see [`crate::walk`]) into preset files
|
||||
/// (`.lcpreset`, `.xmp`).
|
||||
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
|
||||
fn walk(p: &Path, out: &mut Vec<String>, top: bool) {
|
||||
if p.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(p) else { return };
|
||||
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for c in v {
|
||||
if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
walk(&c, out, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let preset_file = |p: &Path| {
|
||||
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str())
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
walk(Path::new(p), &mut out, true);
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let w = crate::walk::files_in(path, None, crate::walk::Limits::default(), preset_file);
|
||||
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
|
||||
} else {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -189,6 +189,12 @@ fn the_folder_filter_takes_only_a_text_path() {
|
||||
assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything");
|
||||
}
|
||||
|
||||
/// Folders are compared by identity: a path read back may be spelled with the platform's separator.
|
||||
fn same_folder(actual: Option<&str>, expected: &str, why: &str) {
|
||||
let key = lightcraft_catalog::query::folder_key;
|
||||
assert_eq!(actual.map(key), Some(key(expected)), "{why}: {actual:?} vs {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("rename");
|
||||
@@ -200,10 +206,10 @@ fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
|
||||
s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder");
|
||||
same_folder(s.filter.library_folder.as_deref(), &renamed, "the choice follows the folder");
|
||||
assert_eq!(s.visible().len(), 2, "and still shows its photos");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str()));
|
||||
same_folder(s.filter.library_folder.as_deref(), &trip, "the choice is the same folder");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
}
|
||||
|
||||
@@ -217,10 +223,10 @@ fn a_moved_folder_keeps_a_chosen_subfolder_chosen() {
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap();
|
||||
s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent");
|
||||
same_folder(s.filter.library_folder.as_deref(), &moved, "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str()));
|
||||
same_folder(s.filter.library_folder.as_deref(), &day1, "the choice is the same folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -229,12 +235,21 @@ fn following_a_folder_reads_both_spellings_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.filter.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent");
|
||||
same_folder(s.filter.library_folder.as_deref(), "/a/c/sub", "a subfolder stays a subfolder, never widens to its parent");
|
||||
s.filter.library_folder = Some("/elsewhere/b".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn following_a_folder_keeps_the_case_of_the_subfolder_names() {
|
||||
// Windows compares folders in lower case; the name the user sees must not change with it
|
||||
let mut s = Session::new();
|
||||
s.filter.library_folder = Some("/A/Trip/Day1".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/A/Trip", "/B");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(std::path::Path::new("/B").join("Day1").to_str().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_replaces_the_source_the_way_an_album_does() {
|
||||
let mut s = session();
|
||||
@@ -296,10 +311,10 @@ fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() {
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
source_folder(&mut s, &day1);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent");
|
||||
same_folder(s.library_folder.as_deref(), &format!("{renamed}/day1"), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(day1.as_str()));
|
||||
same_folder(s.library_folder.as_deref(), &day1, "the choice is the same folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
@@ -308,7 +323,7 @@ fn the_shown_folder_follows_reading_both_spellings_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub"));
|
||||
same_folder(s.library_folder.as_deref(), "/a/c/sub", "the shown folder reads both spellings alike");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1007,3 +1007,101 @@ fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64)
|
||||
let (s, src, id) = trashed_photo(tag);
|
||||
(s, src, [id], 0)
|
||||
}
|
||||
|
||||
/// Issue #374: launch arguments never turn into a walk of a whole drive or home folder. An empty
|
||||
/// argument is ignored; `.`, `..` and (on Windows) `/` are resolved against the working folder a
|
||||
/// launcher picked, which is often a drive root; a root is replaced by its memory card's DCIM
|
||||
/// folder or refused, and so is the home folder.
|
||||
#[test]
|
||||
fn launch_paths_never_become_a_drive_root_or_the_home_folder() {
|
||||
use crate::import::{LaunchPath, launch_path};
|
||||
let root = if cfg!(windows) { Path::new("C:\\") } else { Path::new("/") };
|
||||
let home = if cfg!(windows) { Path::new("C:\\Users\\me") } else { Path::new("/home/me") };
|
||||
let none = |_: &Path| None;
|
||||
let refused = |r: Option<LaunchPath>| matches!(r, Some(LaunchPath::Refused { .. }));
|
||||
for empty in ["", " "] {
|
||||
assert_eq!(launch_path(empty, Some(root), Some(home), none), None, "{empty:?}");
|
||||
}
|
||||
// the working folder is the drive root: `.` (and `..`) name the whole drive
|
||||
for arg in [".", "..", "./", ".\\..", "/"] {
|
||||
let arg = if cfg!(windows) { arg.to_string() } else { arg.replace('\\', "/") };
|
||||
assert!(refused(launch_path(&arg, Some(root), Some(home), none)), "{arg:?}");
|
||||
}
|
||||
assert!(refused(launch_path("..", Some(&home.join("Pictures")), Some(home), none)), "up to the home folder");
|
||||
assert!(refused(launch_path(&home.to_string_lossy(), None, Some(home), none)));
|
||||
assert!(refused(launch_path(".", None, Some(home), none)), "relative, no working folder");
|
||||
// a memory card's root: its camera folder
|
||||
let dcim = |r: &Path| Some(r.join("DCIM"));
|
||||
assert_eq!(launch_path(".", Some(root), Some(home), dcim), Some(LaunchPath::Import(root.join("DCIM").to_string_lossy().to_string())));
|
||||
// ordinary folders and files resolve to absolute paths
|
||||
let pics = home.join("Pictures");
|
||||
assert_eq!(launch_path("Pictures", Some(home), Some(home), none), Some(LaunchPath::Import(pics.to_string_lossy().to_string())));
|
||||
assert_eq!(
|
||||
launch_path(&pics.join("a.jpg").to_string_lossy(), None, Some(home), none),
|
||||
Some(LaunchPath::Import(pics.join("a.jpg").to_string_lossy().to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #374: a folder import is bounded — a tree deeper or larger than the limits is not walked
|
||||
/// to the end (it stops and says so), and what it found is still returned.
|
||||
#[test]
|
||||
fn expand_stops_at_the_walk_limits() {
|
||||
let d = temp_dir("bounded");
|
||||
let mut deep = d.clone();
|
||||
for i in 0..8 {
|
||||
deep = deep.join(format!("d{i}"));
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
std::fs::write(deep.join(format!("p{i}.jpg")), b"x").unwrap();
|
||||
}
|
||||
let top = [d.to_string_lossy().to_string()];
|
||||
let (all, cut) = crate::import::expand_within(&top, None, crate::walk::Limits::default());
|
||||
assert_eq!((all.len(), cut), (8, false));
|
||||
let (some, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 3, max_entries: 1000 });
|
||||
assert_eq!((some.len(), cut), (2, true), "{some:?}");
|
||||
let (few, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 32, max_entries: 5 });
|
||||
assert!(cut && few.len() < 8, "{few:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Raw containers LightCraft cannot decode but shows by their embedded JPEG are picked up from a folder
|
||||
/// (the extensions are in `import::EXTENSIONS`; the files differ so none is a duplicate) and import as preview only.
|
||||
#[test]
|
||||
fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
let src = temp_dir("preview-only-exts");
|
||||
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
// CRW / MRW / X3F: their magic, then the JPEG. IIQ / KDC / MOS / ERF: a TIFF whose IFD0 only points at the JPEG.
|
||||
let tiff = {
|
||||
let off = 8 + 2 + 24 + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] {
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
|
||||
f.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&jpeg);
|
||||
f
|
||||
};
|
||||
let with_magic = |magic: &[u8]| [magic, &[0x11u8; 64][..], &jpeg].concat();
|
||||
let files: [(&str, Vec<u8>); 7] = [
|
||||
("a.CRW", with_magic(b"II\x1a\0\0\0HEAPCCDR")),
|
||||
("b.mrw", with_magic(b"\0MRM\0\x01\0\0")),
|
||||
("c.x3f", with_magic(b"FOVb\x02\0\x02\0")),
|
||||
("d.iiq", [&tiff[..], b"d"].concat()),
|
||||
("e.kdc", [&tiff[..], b"e"].concat()),
|
||||
("f.mos", [&tiff[..], b"f"].concat()),
|
||||
("g.erf", [&tiff[..], b"g"].concat()),
|
||||
];
|
||||
for (name, bytes) in &files {
|
||||
std::fs::write(src.join(name), bytes).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["scanned"], 7, "{r}");
|
||||
assert_eq!(ids(&r, "imported"), 7, "{r}");
|
||||
assert_eq!(ids(&r, "failed"), 0, "{r}");
|
||||
assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30)));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
@@ -433,3 +433,70 @@ fn write_policy_timing() {
|
||||
println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #367: Import → Copy wrote and verified one file after another on one thread. The copies
|
||||
/// now run side by side, with the names, order, duplicates and reports a one-by-one copy gives:
|
||||
/// like-named files take -1, -2… in file order (also against a file already there and a literal
|
||||
/// `IMG_1-1`), and a file with the content of one copied earlier in the batch is its duplicate
|
||||
/// (also while that copy is still under way).
|
||||
#[test]
|
||||
fn copies_run_side_by_side_with_the_same_outcome() {
|
||||
let base = temp_dir("parallel-copy");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a/IMG_1.png"), 1);
|
||||
write_png(&card.join("a/IMG_2.png"), 2);
|
||||
write_png(&card.join("b/IMG_1.png"), 3);
|
||||
write_png(&card.join("b/IMG_1-1.png"), 4);
|
||||
write_png(&card.join("c/IMG_1.png"), 5);
|
||||
for i in 0..8u8 {
|
||||
write_png(&card.join(format!("d/p{i}.png")), 10 + i);
|
||||
}
|
||||
// the content of a file whose copy is still under way
|
||||
std::fs::create_dir_all(card.join("e")).unwrap();
|
||||
std::fs::copy(card.join("d/p3.png"), card.join("e/dup.png")).unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::write(dest.join("IMG_2.png"), b"someone else's file").unwrap();
|
||||
|
||||
let mut s = session();
|
||||
inject(Fault::Slow);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
|
||||
)
|
||||
.unwrap();
|
||||
inject(Fault::None);
|
||||
let threads: std::collections::HashSet<_> = crate::import_move::copy_threads().into_iter().collect();
|
||||
if std::thread::available_parallelism().map_or(1, |n| n.get()) > 1 {
|
||||
assert!(threads.len() > 1, "copies ran on {} thread(s)", threads.len());
|
||||
}
|
||||
|
||||
assert_eq!(len(&r, "imported"), 13, "{r}");
|
||||
assert_eq!(len(&r, "failed"), 0, "{r}");
|
||||
assert_eq!(len(&r, "duplicates"), 1, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["reason"], "content");
|
||||
assert!(r["duplicates"][0]["path"].as_str().unwrap().ends_with("dup.png"), "{r}");
|
||||
// the order of a one-by-one copy (files in name order), each verified
|
||||
let expect = [
|
||||
("a/IMG_1.png", "IMG_1.png"),
|
||||
("a/IMG_2.png", "IMG_2-1.png"),
|
||||
("b/IMG_1-1.png", "IMG_1-1.png"),
|
||||
("b/IMG_1.png", "IMG_1-2.png"),
|
||||
("c/IMG_1.png", "IMG_1-3.png"),
|
||||
];
|
||||
for (src, dst) in expect {
|
||||
assert_eq!(std::fs::read(dest.join(dst)).unwrap(), std::fs::read(card.join(src)).unwrap(), "{src} → {dst}");
|
||||
}
|
||||
for i in 0..8 {
|
||||
assert_eq!(std::fs::read(dest.join(format!("p{i}.png"))).unwrap(), std::fs::read(card.join(format!("d/p{i}.png"))).unwrap());
|
||||
}
|
||||
assert_eq!(std::fs::read(dest.join("IMG_2.png")).unwrap(), b"someone else's file");
|
||||
assert_eq!(files_under(&dest).len(), 14, "{:?}", files_under(&dest));
|
||||
// the duplicate names the photo copied from the same content (d/p3); ids follow the file order
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}");
|
||||
assert_eq!(r["duplicates"][0]["existing"], ids[8], "{r}");
|
||||
let first = s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap();
|
||||
assert_eq!(first.file_name, "IMG_1.png");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Bounded folder walks (issue #374): every recursive listing of the disk — import, Local's
|
||||
//! Include Subfolders, Find Missing Photos, preset and LUT folders, MCP — goes through
|
||||
//! [`files_in`], which stops at a depth and a number of directory entries instead of walking a
|
||||
//! whole drive, follows a symbolic link or junction to a folder at most once (no loops), and
|
||||
//! decides what is a folder from the directory listing itself (no extra open per entry, which
|
||||
//! is what makes a large walk slow on Windows).
|
||||
//!
|
||||
//! It never chooses where to walk: callers pass a folder the user named. Nothing in the app walks
|
||||
//! at startup or on the UI thread.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How far a walk may go.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Folder levels below the starting folder that are listed (its own entries are level 1).
|
||||
pub max_depth: usize,
|
||||
/// Directory entries looked at in all (files and folders, hidden ones included).
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { max_depth: MAX_DEPTH, max_entries: MAX_ENTRIES }
|
||||
}
|
||||
}
|
||||
|
||||
/// Folder levels a walk descends at most; photo trees are a handful of levels deep.
|
||||
pub const MAX_DEPTH: usize = 32;
|
||||
/// Directory entries a walk looks at at most: far more than any photo folder holds, far fewer
|
||||
/// than a whole system drive.
|
||||
pub const MAX_ENTRIES: usize = 1_000_000;
|
||||
|
||||
/// What a walk found.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Walked {
|
||||
/// The files `keep` accepted, in path order, folder by folder (depth first).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// The walk stopped at a [`Limits`] bound: there may be more files.
|
||||
pub truncated: bool,
|
||||
/// Directory entries looked at.
|
||||
pub entries: usize,
|
||||
}
|
||||
|
||||
fn hidden(p: &Path) -> bool {
|
||||
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
}
|
||||
|
||||
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
|
||||
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
|
||||
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
|
||||
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
|
||||
let mut out = Walked::default();
|
||||
// canonical folders reached through a link, so a link loop is walked at most once
|
||||
let mut linked: HashSet<PathBuf> = HashSet::new();
|
||||
if let Ok(c) = std::fs::canonicalize(root) {
|
||||
linked.insert(c);
|
||||
}
|
||||
// (path, depth of the folder it is in, is a folder); popped in path order
|
||||
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
|
||||
while let Some((path, depth, is_dir)) = stack.pop() {
|
||||
if !is_dir {
|
||||
if keep(&path) {
|
||||
out.files.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip.is_some_and(|s| path == s) {
|
||||
continue;
|
||||
}
|
||||
if depth >= limits.max_depth {
|
||||
out.truncated = true;
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&path) else { continue };
|
||||
let mut children: Vec<(PathBuf, bool)> = Vec::new();
|
||||
for e in rd.flatten() {
|
||||
if out.entries >= limits.max_entries {
|
||||
out.truncated = true;
|
||||
break;
|
||||
}
|
||||
out.entries = out.entries.saturating_add(1);
|
||||
let p = e.path();
|
||||
if hidden(&p) {
|
||||
continue;
|
||||
}
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
let dir = if ft.is_symlink() {
|
||||
// a link (or a Windows junction): a folder is entered once, a file is a file
|
||||
match std::fs::metadata(&p) {
|
||||
Ok(m) if m.is_dir() => {
|
||||
let Ok(c) = std::fs::canonicalize(&p) else { continue };
|
||||
if !linked.insert(c) {
|
||||
continue;
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
ft.is_dir()
|
||||
};
|
||||
children.push((p, dir));
|
||||
}
|
||||
children.sort();
|
||||
let below = depth.saturating_add(1);
|
||||
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-walk-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
|
||||
let d = tmp("order");
|
||||
for f in ["b/2.jpg", "a/1.jpg", "a/x/0.jpg", "c.jpg", ".hidden/h.jpg", "a/.h.jpg", "lib/l.jpg", "a/n.txt"] {
|
||||
let p = d.join(f);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, b"x").unwrap();
|
||||
}
|
||||
let lib = d.join("lib");
|
||||
let w = files_in(&d, Some(&lib), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
|
||||
let rel: Vec<String> = w.files.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect();
|
||||
assert_eq!(rel, ["a/1.jpg", "a/x/0.jpg", "b/2.jpg", "c.jpg"]);
|
||||
assert!(!w.truncated);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #374: a walk never runs on without end — a tree deeper than the limit, or with more
|
||||
/// entries than the limit, stops and says so.
|
||||
#[test]
|
||||
fn deep_and_wide_trees_stop_at_the_limits() {
|
||||
let d = tmp("bounds");
|
||||
let mut deep = d.join("deep");
|
||||
for i in 0..12 {
|
||||
deep = deep.join(format!("l{i}"));
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
std::fs::write(deep.join("bottom.jpg"), b"x").unwrap();
|
||||
let wide = d.join("wide");
|
||||
std::fs::create_dir_all(&wide).unwrap();
|
||||
for i in 0..50 {
|
||||
std::fs::write(wide.join(format!("{i:02}.jpg")), b"x").unwrap();
|
||||
}
|
||||
let all = |_: &Path| true;
|
||||
let w = files_in(&d.join("deep"), None, Limits { max_depth: 5, max_entries: 1000 }, all);
|
||||
assert!(w.files.is_empty() && w.truncated, "{w:?}");
|
||||
let w = files_in(&d.join("deep"), None, Limits { max_depth: 13, max_entries: 1000 }, all);
|
||||
assert_eq!((w.files.len(), w.truncated), (1, false), "{w:?}");
|
||||
let w = files_in(&wide, None, Limits { max_depth: 5, max_entries: 20 }, all);
|
||||
assert_eq!((w.files.len(), w.entries, w.truncated), (20, 20, true));
|
||||
let w = files_in(&wide, None, Limits::default(), all);
|
||||
assert_eq!((w.files.len(), w.truncated), (50, false));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A link back up the tree (a loop) is followed at most once: the walk ends.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn link_loops_end() {
|
||||
let d = tmp("loop");
|
||||
std::fs::create_dir_all(d.join("a/b")).unwrap();
|
||||
std::fs::write(d.join("a/b/p.jpg"), b"x").unwrap();
|
||||
std::os::unix::fs::symlink(&d, d.join("a/b/up")).unwrap();
|
||||
std::os::unix::fs::symlink(d.join("a"), d.join("alias")).unwrap();
|
||||
let w = files_in(&d, None, Limits::default(), |_| true);
|
||||
assert!(!w.truncated, "{w:?}");
|
||||
assert!(w.files.len() <= 2, "{w:?}");
|
||||
assert!(w.files.iter().any(|p| p.ends_with("a/b/p.jpg")), "{w:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ use crate::backend::Backend;
|
||||
/// File extensions recognised as photos when expanding folders.
|
||||
pub const PHOTO_EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
|
||||
"bmp", "avif",
|
||||
"bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
];
|
||||
|
||||
/// Headless backend: a [`Session`] with filesystem hooks.
|
||||
@@ -139,31 +140,20 @@ impl Backend for Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand folders (recursively, sorted) into photo files and make paths absolute.
|
||||
/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files
|
||||
/// and make paths absolute.
|
||||
pub fn expand_paths(paths: &[String]) -> Vec<String> {
|
||||
fn walk(p: &Path, out: &mut Vec<String>) {
|
||||
if p.is_dir() {
|
||||
if let Ok(rd) = std::fs::read_dir(p) {
|
||||
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for c in v {
|
||||
if c.file_name().is_some_and(|n| !n.to_string_lossy().starts_with('.')) {
|
||||
walk(&c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) {
|
||||
out.push(std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string();
|
||||
let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()));
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
walk(path, &mut out);
|
||||
let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo);
|
||||
out.extend(w.files.iter().map(|f| absolute(f)));
|
||||
} else {
|
||||
// Explicit files are kept even with unknown extensions (the probe decides).
|
||||
out.push(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string());
|
||||
out.push(absolute(path));
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
+239
-5
@@ -83,6 +83,12 @@ pub enum RawFormat {
|
||||
Rw2,
|
||||
Pef,
|
||||
Srw,
|
||||
/// Canon CRW (CIFF heap), the bodies before the CR2 era.
|
||||
Crw,
|
||||
/// Minolta MRW (`\0MRM` block container).
|
||||
Mrw,
|
||||
/// Sigma / Foveon X3F (`FOVb`).
|
||||
X3f,
|
||||
/// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …).
|
||||
OtherTiff,
|
||||
}
|
||||
@@ -119,6 +125,15 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
if bytes.starts_with(b"IIU\0") {
|
||||
return Some(RawFormat::Rw2);
|
||||
}
|
||||
if bytes.starts_with(b"\0MRM") {
|
||||
return Some(RawFormat::Mrw);
|
||||
}
|
||||
if bytes.starts_with(b"FOVb") {
|
||||
return Some(RawFormat::X3f);
|
||||
}
|
||||
if bytes.len() >= 14 && bytes.starts_with(b"II") && &bytes[6..14] == b"HEAPCCDR" {
|
||||
return Some(RawFormat::Crw);
|
||||
}
|
||||
if bytes.len() >= 10 && bytes.starts_with(b"II*\0") && &bytes[8..10] == b"CR" {
|
||||
return Some(RawFormat::Cr2);
|
||||
}
|
||||
@@ -130,10 +145,7 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
return Some(RawFormat::Dng);
|
||||
}
|
||||
let make = t.find(lightcraft_tiff::tags::MAKE).and_then(|e| e.value.as_str()).unwrap_or_default().to_ascii_uppercase();
|
||||
let has_cfa = t.all_ifds().iter().any(|i| {
|
||||
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|
||||
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770)
|
||||
});
|
||||
let has_cfa = has_raw_ifd(&t);
|
||||
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
|
||||
return Some(RawFormat::Cr2);
|
||||
}
|
||||
@@ -149,12 +161,103 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
if make.starts_with("SAMSUNG") {
|
||||
return Some(RawFormat::Srw);
|
||||
}
|
||||
if has_cfa {
|
||||
if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) {
|
||||
return Some(RawFormat::OtherTiff);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a
|
||||
/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw).
|
||||
fn has_raw_ifd(t: &Tiff) -> bool {
|
||||
t.all_ifds().iter().any(|i| {
|
||||
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|
||||
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770 || c == 99)
|
||||
})
|
||||
}
|
||||
|
||||
/// IFD0 that is not an image at all: no `ImageWidth` / `ImageLength`, but a JPEG preview pointer or
|
||||
/// `SubIFDs` (the layout of Kodak KDC files, whose pictures sit in private tags). A plain TIFF reader
|
||||
/// rejects such a file as malformed even though it carries a preview.
|
||||
fn is_preview_container(ifd0: &lightcraft_tiff::Ifd) -> bool {
|
||||
use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, JPEG_INTERCHANGE_FORMAT, SUB_IFDS};
|
||||
!ifd0.contains(IMAGE_WIDTH) && !ifd0.contains(IMAGE_LENGTH) && (ifd0.contains(JPEG_INTERCHANGE_FORMAT) || ifd0.contains(SUB_IFDS))
|
||||
}
|
||||
|
||||
/// A TIFF whose first image is only a reduced copy of a picture stored somewhere else.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ThumbnailShell {
|
||||
/// Size of the first image (IFD0).
|
||||
pub thumb: (u32, u32),
|
||||
/// Size of the picture it stands in for.
|
||||
pub full: (u32, u32),
|
||||
}
|
||||
|
||||
/// Some raw files are a TIFF "shell" around a private block: IFD0 holds a small RGB preview and nothing
|
||||
/// in the TIFF structure marks the real image as raw (no CFA photometric, no raw compression). The
|
||||
/// structure still gives them away, with a generic rule that names no maker or model: the file
|
||||
/// declares a picture at least 4x larger than IFD0 in both directions, and either
|
||||
/// - a `SubIFDs` child of IFD0 is that bigger image (an 8-bit grey mosaic in one such file), or
|
||||
/// - the Exif `PixelXDimension` / `PixelYDimension` say so *and* the file holds enough bytes outside
|
||||
/// every IFD's strips and tiles to carry that many pixels at one bit each (a downsized copy that kept
|
||||
/// its old Exif size has no such block).
|
||||
///
|
||||
/// A multi-page TIFF is not caught (its pages are the IFD chain, not `SubIFDs`), nor a pyramid whose
|
||||
/// IFD0 is the full image.
|
||||
fn thumbnail_shell(t: &Tiff, len: usize) -> Option<ThumbnailShell> {
|
||||
use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, PIXEL_X_DIMENSION, PIXEL_Y_DIMENSION};
|
||||
let ifd0 = t.ifds.first()?;
|
||||
let (w0, h0) = (ifd0.u32(IMAGE_WIDTH)?, ifd0.u32(IMAGE_LENGTH)?);
|
||||
if w0 == 0 || h0 == 0 {
|
||||
return None;
|
||||
}
|
||||
let much_bigger = |(w, h): (u32, u32)| u64::from(w) >= 4 * u64::from(w0) && u64::from(h) >= 4 * u64::from(h0);
|
||||
let shell = |full| Some(ThumbnailShell { thumb: (w0, h0), full });
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
for s in &ifd0.sub_ifds {
|
||||
if s.image().is_ok()
|
||||
&& let (Some(w), Some(h)) = (s.u32(IMAGE_WIDTH), s.u32(IMAGE_LENGTH))
|
||||
&& much_bigger((w, h))
|
||||
&& best.is_none_or(|(bw, bh)| u64::from(w) * u64::from(h) > u64::from(bw) * u64::from(bh))
|
||||
{
|
||||
best = Some((w, h));
|
||||
}
|
||||
}
|
||||
if let Some(full) = best {
|
||||
return shell(full);
|
||||
}
|
||||
let exif = t.exif()?;
|
||||
let full = (exif.u32(PIXEL_X_DIMENSION)?, exif.u32(PIXEL_Y_DIMENSION)?);
|
||||
if !much_bigger(full) {
|
||||
return None;
|
||||
}
|
||||
// bytes covered by image data (strips and tiles shared between IFDs, e.g. IFD0 and its thumbnail, count once)
|
||||
let mut seen = std::collections::BTreeMap::new();
|
||||
for ifd in t.all_ifds() {
|
||||
if let Ok(info) = ifd.image() {
|
||||
for c in info.chunks(len as u64) {
|
||||
seen.insert(c.offset, c.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
let covered = seen.values().fold(0u64, |a, &l| a.saturating_add(l));
|
||||
let spare = (len as u64).saturating_sub(covered);
|
||||
(spare.saturating_mul(8) >= u64::from(full.0) * u64::from(full.1)).then_some(())?;
|
||||
shell(full)
|
||||
}
|
||||
|
||||
/// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`].
|
||||
fn other_tiff_reason(bytes: &[u8]) -> String {
|
||||
let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok();
|
||||
match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) {
|
||||
Some(s) => format!(
|
||||
"{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)",
|
||||
s.full.0, s.full.1, s.thumb.0, s.thumb.1
|
||||
),
|
||||
None => "OtherTiff files are not decoded yet".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a raw file.
|
||||
pub fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
decode_with(bytes, Mode::Full)
|
||||
@@ -195,6 +298,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
|
||||
RawFormat::Pef => vendor::pef::decode(bytes, mode),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes, mode),
|
||||
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
|
||||
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
|
||||
}
|
||||
}
|
||||
@@ -623,6 +727,136 @@ mod tests {
|
||||
assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0);
|
||||
}
|
||||
|
||||
// --- TIFF shells around a private raw block (thumbnail-only IFD0) ---
|
||||
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
|
||||
/// An uncompressed 8-bit RGB image IFD.
|
||||
fn rgb_ifd(w: u32, h: u32) -> IfdBuilder {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] });
|
||||
ifd
|
||||
}
|
||||
|
||||
fn exif_size(w: u32, h: u32) -> IfdBuilder {
|
||||
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h]))
|
||||
}
|
||||
|
||||
fn write(chain: &[IfdBuilder]) -> Vec<u8> {
|
||||
TiffWriter::default().write(chain).unwrap()
|
||||
}
|
||||
|
||||
const SHELL_REASON: &str = "raw image in a private block";
|
||||
|
||||
/// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw).
|
||||
#[test]
|
||||
fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() {
|
||||
let mut ifd0 = rgb_ifd(16, 12);
|
||||
ifd0.add_sub_ifd(rgb_ifd(64, 48));
|
||||
let bytes = write(&[ifd0]);
|
||||
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
|
||||
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
|
||||
assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}");
|
||||
// a sub-IFD that is not much bigger (a pyramid level) does not count
|
||||
let mut ifd0 = rgb_ifd(16, 12);
|
||||
ifd0.add_sub_ifd(rgb_ifd(32, 24));
|
||||
assert_eq!(probe(&write(&[ifd0])), None);
|
||||
}
|
||||
|
||||
/// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it.
|
||||
#[test]
|
||||
fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() {
|
||||
let mut ifd0 = rgb_ifd(16, 12);
|
||||
ifd0.set_child(t::EXIF_IFD, exif_size(400, 300));
|
||||
let mut bytes = write(&[ifd0.clone()]);
|
||||
// no private block: a downsized copy that kept its old Exif size is an ordinary image
|
||||
assert_eq!(probe(&bytes), None);
|
||||
// 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is
|
||||
bytes.extend(std::iter::repeat_n(7u8, 14_000));
|
||||
assert_eq!(probe(&bytes), None);
|
||||
bytes.extend(std::iter::repeat_n(7u8, 2_000));
|
||||
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
|
||||
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
|
||||
assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}");
|
||||
// truncating it never panics
|
||||
for n in (0..bytes.len()).step_by(37) {
|
||||
let _ = probe(&bytes[..n]);
|
||||
let _ = decode(&bytes[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger.
|
||||
#[test]
|
||||
fn ordinary_tiffs_are_not_shells() {
|
||||
let padded = |chain: &[IfdBuilder]| {
|
||||
let mut b = write(chain);
|
||||
b.extend(std::iter::repeat_n(0u8, 100_000));
|
||||
b
|
||||
};
|
||||
let mut full = rgb_ifd(400, 300);
|
||||
full.set_child(t::EXIF_IFD, exif_size(400, 300));
|
||||
assert_eq!(probe(&padded(&[full])), None);
|
||||
// the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it
|
||||
let mut near = rgb_ifd(120, 90);
|
||||
near.set_child(t::EXIF_IFD, exif_size(400, 300));
|
||||
assert_eq!(probe(&padded(&[near])), None);
|
||||
// a multi-page TIFF whose first page is small
|
||||
assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None);
|
||||
// no dimensions at all
|
||||
assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None);
|
||||
}
|
||||
|
||||
// --- containers that are recognised but not decoded, with a preview ---
|
||||
|
||||
#[test]
|
||||
fn private_containers_are_recognised_by_their_magic() {
|
||||
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
|
||||
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
|
||||
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
|
||||
for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] {
|
||||
assert!(!f.is_supported());
|
||||
}
|
||||
assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
|
||||
// too short for the CIFF signature
|
||||
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
|
||||
}
|
||||
|
||||
/// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw
|
||||
/// container with a preview; a TIFF reader would call it malformed.
|
||||
#[test]
|
||||
fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() {
|
||||
let jpeg: Vec<u8> = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat();
|
||||
let off = 8 + 2 + 24 + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] {
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
|
||||
f.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&jpeg);
|
||||
assert_eq!(probe(&f), Some(RawFormat::OtherTiff));
|
||||
assert!(matches!(decode(&f), Err(RawError::Unsupported(_))));
|
||||
assert_eq!(embedded_preview(&f), Some(jpeg));
|
||||
// no pointer and no sub-IFDs: just a broken TIFF
|
||||
let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]);
|
||||
assert_eq!(probe(&g), None);
|
||||
}
|
||||
|
||||
/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag.
|
||||
#[test]
|
||||
fn private_compression_99_is_a_raw() {
|
||||
let mut ifd = rgb_ifd(16, 12);
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![99]));
|
||||
assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_rejects_junk() {
|
||||
assert_eq!(probe(b""), None);
|
||||
|
||||
+173
-2
@@ -91,9 +91,13 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
|
||||
return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec());
|
||||
}
|
||||
if crate::probe(bytes) == Some(crate::RawFormat::Cr3) {
|
||||
let format = crate::probe(bytes);
|
||||
if format == Some(crate::RawFormat::Cr3) {
|
||||
return cr3_preview(bytes).map(|j| trim_eoi(j).to_vec());
|
||||
}
|
||||
if matches!(format, Some(crate::RawFormat::Crw | crate::RawFormat::Mrw | crate::RawFormat::X3f)) {
|
||||
return scan_for_jpeg(bytes);
|
||||
}
|
||||
let tiff = Tiff::parse(bytes).ok()?;
|
||||
let mut found: Vec<&[u8]> = Vec::new();
|
||||
for ifd in tiff.all_ifds() {
|
||||
@@ -117,7 +121,112 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(p) = crate::vendor::orf::preview(bytes) {
|
||||
found.push(p);
|
||||
}
|
||||
found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec())
|
||||
let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec());
|
||||
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
|
||||
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
|
||||
}
|
||||
|
||||
/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count,
|
||||
/// 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`]).
|
||||
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);
|
||||
*reach = i;
|
||||
r
|
||||
}
|
||||
|
||||
fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> {
|
||||
let mut components = None;
|
||||
for _ in 0..4096 {
|
||||
if *b.get(*i)? != 0xff {
|
||||
return None;
|
||||
}
|
||||
while *b.get(*i)? == 0xff {
|
||||
*i += 1;
|
||||
}
|
||||
let m = *b.get(*i)?;
|
||||
*i += 1;
|
||||
match m {
|
||||
0xd9 => return components.map(|c| (*i, c)),
|
||||
0x01 | 0xd0..=0xd8 => continue,
|
||||
_ => {}
|
||||
}
|
||||
let len = usize::from(u16::from_be_bytes([*b.get(*i)?, *b.get(*i + 1)?]));
|
||||
if len < 2 {
|
||||
return None;
|
||||
}
|
||||
match m {
|
||||
0xc0..=0xc2 => {
|
||||
// precision, height, width, component count
|
||||
let h = u16::from_be_bytes([*b.get(*i + 3)?, *b.get(*i + 4)?]);
|
||||
let w = u16::from_be_bytes([*b.get(*i + 5)?, *b.get(*i + 6)?]);
|
||||
if h == 0 || w == 0 || components.is_some() {
|
||||
return None;
|
||||
}
|
||||
components = Some(*b.get(*i + 7)?);
|
||||
}
|
||||
0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf => return None, // lossless, arithmetic, hierarchical
|
||||
_ => {}
|
||||
}
|
||||
*i += len;
|
||||
if m == 0xda {
|
||||
// entropy-coded data up to the next marker that is not a stuffed 0xff or a restart
|
||||
loop {
|
||||
*i += b.get(*i..)?.iter().position(|&x| x == 0xff)?;
|
||||
match *b.get(*i + 1)? {
|
||||
0x00 | 0xd0..=0xd7 => *i += 2,
|
||||
0xff => *i += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Last resort for the containers whose structure is not walked (Canon CRW, Minolta MRW, Sigma X3F, TIFF raws
|
||||
/// with private blocks such as Leaf MOS or Epson ERF): the largest complete colour DCT JPEG stored as a plain
|
||||
/// byte run in the file. A one-component JPEG is skipped (a Canon PowerShot CRW stores its sensor mosaic as one).
|
||||
///
|
||||
/// Evidence: in the Minolta and Epson files the first byte of the stored preview is not `ff` (it is `00`, `02` or
|
||||
/// `ee`) while the rest is a regular JPEG, so a run is found by its `d8 ff` and starts one byte earlier,
|
||||
/// with that byte restored to `ff`.
|
||||
///
|
||||
/// A crafted file can hold millions of JPEG-like starts whose walks each run to its end: the walks
|
||||
/// share a budget of eight times the file's size, so the scan stays linear (a real file's candidates
|
||||
/// fail within a few bytes, or are the preview itself).
|
||||
fn scan_for_jpeg(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
let mut at = 1;
|
||||
let mut budget = data.len().saturating_mul(8).max(1 << 20);
|
||||
while let Some(off) = data.get(at..).and_then(|s| s.iter().position(|&b| b == 0xd8)) {
|
||||
let d8 = at + off;
|
||||
at = d8 + 1;
|
||||
if data.get(d8 + 1) != Some(&0xff) || !matches!(data.get(d8 + 2), Some(0xc0..=0xc4 | 0xdb | 0xe0..=0xef | 0xfe)) {
|
||||
continue;
|
||||
}
|
||||
let start = d8 - 1; // `at` starts at 1, so d8 >= 1
|
||||
let mut reach = start;
|
||||
let found = jpeg_extent(data, start, &mut reach);
|
||||
budget = budget.saturating_sub(reach.saturating_sub(start));
|
||||
if budget == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some((end, 3)) = found {
|
||||
if best.is_none_or(|(s, e)| end - start > e - s) {
|
||||
best = Some((start, end));
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
let (start, end) = best?;
|
||||
let mut jpeg = data.get(start..end)?.to_vec();
|
||||
if let Some(first) = jpeg.first_mut() {
|
||||
*first = 0xff;
|
||||
}
|
||||
Some(jpeg)
|
||||
}
|
||||
|
||||
/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes.
|
||||
@@ -242,4 +351,66 @@ mod tests {
|
||||
file.truncate(at as usize + 100);
|
||||
assert_eq!(embedded_preview(&file).unwrap(), small);
|
||||
}
|
||||
|
||||
/// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes.
|
||||
fn scan_jpeg(nc: u8, n: usize) -> Vec<u8> {
|
||||
let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00];
|
||||
j.extend(std::iter::repeat_n(8u8, 64));
|
||||
j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]);
|
||||
for c in 0..nc {
|
||||
j.extend_from_slice(&[c + 1, 0x11, 0]);
|
||||
}
|
||||
j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]);
|
||||
for c in 0..nc {
|
||||
j.extend_from_slice(&[c + 1, 0]);
|
||||
}
|
||||
j.extend_from_slice(&[0, 63, 0]);
|
||||
j.extend(std::iter::repeat_n(0x55u8, n));
|
||||
j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]);
|
||||
j
|
||||
}
|
||||
|
||||
/// A crafted container full of JPEG-like starts (each walk running on through the ones after it)
|
||||
/// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute).
|
||||
#[test]
|
||||
fn a_scan_through_many_fake_jpegs_stays_linear() {
|
||||
let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02];
|
||||
unit.extend(std::iter::repeat_n(0x55u8, 190));
|
||||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||||
for _ in 0..200_000 {
|
||||
f.extend_from_slice(&unit);
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(scan_for_jpeg(&f), None);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning.
|
||||
#[test]
|
||||
fn scanned_containers_give_the_largest_colour_jpeg() {
|
||||
let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000));
|
||||
let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"];
|
||||
for head in heads {
|
||||
let mut f = head.to_vec();
|
||||
for part in [&small, &mosaic, &big] {
|
||||
f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG
|
||||
f.extend_from_slice(part);
|
||||
f.extend_from_slice(&[0u8; 7]);
|
||||
}
|
||||
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}");
|
||||
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
|
||||
for n in (0..f.len()).step_by(11) {
|
||||
let _ = embedded_preview(&f[..n]);
|
||||
}
|
||||
}
|
||||
// a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored
|
||||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||||
f.push(0x02);
|
||||
f.extend_from_slice(&big[1..]);
|
||||
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]));
|
||||
// only a one-component JPEG, or none: nothing
|
||||
let mut f = b"FOVb\x02\0\x02\0".to_vec();
|
||||
f.extend_from_slice(&mosaic);
|
||||
assert_eq!(embedded_preview(&f), None);
|
||||
}
|
||||
}
|
||||
Vendored
+59
-3
@@ -103,21 +103,33 @@ pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result<Vec<u16>>
|
||||
output.resize(count, 0);
|
||||
}
|
||||
for (i, &value) in decoded.iter().enumerate() {
|
||||
let sensor_value =
|
||||
value.checked_add(midpoint).filter(|&v| v >= 0 && v <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
|
||||
let sensor_value = sensor_sample(value, midpoint, maximum, config.levels != 0)?;
|
||||
let (x, y) = (tile.x + 2 * (i % (tile.width / 2)) + p % 2, tile.y + 2 * (i / (tile.width / 2)) + p / 2);
|
||||
let slot = y
|
||||
.checked_mul(width)
|
||||
.and_then(|n| n.checked_add(x))
|
||||
.and_then(|n| output.get_mut(n))
|
||||
.ok_or_else(|| corrupt("tile writes outside image"))?;
|
||||
*slot = sensor_value as u16;
|
||||
*slot = sensor_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// A decoded plane value as a sensor sample of the CMP1 bit depth.
|
||||
///
|
||||
/// Lossless planes reproduce the sensor exactly, so a value outside the bit depth is corrupt data.
|
||||
/// Wavelet (C-RAW) planes are quantized: next to clipped highlights the reconstruction overshoots
|
||||
/// the sensor's range by up to a few quantization steps (measured on 17 CC0 files: at most 36 codes
|
||||
/// above 16383 at 14 bits, never below 0), while lossless files of the same bodies clip at exactly
|
||||
/// 16383. Those samples are clamped to the range the sensor can record.
|
||||
fn sensor_sample(value: i32, midpoint: i32, maximum: i32, quantized: bool) -> Result<u16> {
|
||||
let sample = value.checked_add(midpoint).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
|
||||
let sample = if quantized { sample.clamp(0, maximum) } else { sample };
|
||||
u16::try_from(sample).ok().filter(|_| sample <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))
|
||||
}
|
||||
|
||||
/// Header-only probing must reject the same unsupported coding variants as full decoding.
|
||||
pub(crate) fn validate(sample: &[u8], config: &Cr3Compression) -> Result<()> {
|
||||
supports(config)?;
|
||||
@@ -913,6 +925,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() {
|
||||
// As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200
|
||||
// (Rice escape: 41 zeros, 1, 21-bit literal 16400 = signed +8200). Flat synthesis gives
|
||||
// 8192 + 8200 = 16392, 9 codes above the 14-bit maximum, as in clipped C-RAW highlights.
|
||||
let mut config = coding();
|
||||
config.width = 16;
|
||||
config.height = 16;
|
||||
config.tile_width = 16;
|
||||
config.tile_height = 16;
|
||||
config.header_size = 540;
|
||||
config.levels = 3;
|
||||
let mut sample = Vec::new();
|
||||
marker(&mut sample, 0xff01, 80, 0);
|
||||
for plane in 0..4 {
|
||||
marker(&mut sample, 0xff02, 20, (plane << 28) | 0x0800_0000);
|
||||
for band in 0..10 {
|
||||
marker(&mut sample, 0xff03, [8, 1, 1, 1, 1, 1, 1, 2, 2, 2][band as usize], (band << 28) | 0x0020_0000);
|
||||
}
|
||||
}
|
||||
for _ in 0..4 {
|
||||
sample.extend([0, 0, 0, 0, 0, 0x40, 0x80, 0x20]); // LL3 = +8200.
|
||||
sample.extend([0x80; 3]); // The three 1×1 HF3 bands.
|
||||
sample.extend([0xf0; 3]); // Three 2×2 all-zero HF2 bands.
|
||||
for _ in 0..3 {
|
||||
sample.extend([0xff, 0xf8]); // Three 4×4 all-zero HF1 bands.
|
||||
}
|
||||
}
|
||||
validate(&sample, &config).unwrap();
|
||||
assert_eq!(decode(&sample, &config).unwrap(), vec![16383; 256]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_quantized_planes_clamp_out_of_range_samples() {
|
||||
assert_eq!(sensor_sample(8200, 8192, 16383, true).unwrap(), 16383);
|
||||
assert_eq!(sensor_sample(-8300, 8192, 16383, true).unwrap(), 0);
|
||||
assert_eq!(sensor_sample(8191, 8192, 16383, false).unwrap(), 16383);
|
||||
assert_eq!(sensor_sample(-8192, 8192, 16383, false).unwrap(), 0);
|
||||
// Lossless planes reproduce the sensor exactly: outside the bit depth is corrupt data.
|
||||
assert!(matches!(sensor_sample(8192, 8192, 16383, false), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(sensor_sample(-8193, 8192, 16383, false), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(sensor_sample(i32::MAX, 8192, 16383, true), Err(RawError::Corrupt(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_quantization_and_allocation_limits() {
|
||||
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] {
|
||||
|
||||
@@ -534,3 +534,38 @@ fn corpus_fujifilm_compressed_samples() {
|
||||
}
|
||||
eprintln!("verified {seen} Fujifilm compressed sensor arrays");
|
||||
}
|
||||
|
||||
/// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real
|
||||
/// raws: every corpus file keeps the format its maker implies, and none is described as a private block.
|
||||
#[test]
|
||||
fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
let mut checked = 0;
|
||||
for p in rd.flatten().map(|e| e.path()).filter(|p| p.is_file()) {
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
|
||||
let expected = match name.split(['-', '.']).next().unwrap_or_default() {
|
||||
"arw" => RawFormat::Arw,
|
||||
"cr2" => RawFormat::Cr2,
|
||||
"cr3" => RawFormat::Cr3,
|
||||
"dng" => RawFormat::Dng,
|
||||
"nef" => RawFormat::Nef,
|
||||
"nrw" => RawFormat::Nef, // probed as Nef when the file has several IFDs
|
||||
"orf" => RawFormat::Orf,
|
||||
"pef" => RawFormat::Pef,
|
||||
"raf" => RawFormat::Raf,
|
||||
"rw2" | "rwl" | "raw" => RawFormat::Rw2,
|
||||
_ => continue,
|
||||
};
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(probe(&bytes), Some(expected), "{name}");
|
||||
if let Err(RawError::Unsupported(why)) = probe_info(&bytes) {
|
||||
assert!(!why.contains("private block"), "{name}: {why}");
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
eprintln!("{checked} corpus raws keep their container");
|
||||
}
|
||||
@@ -69,6 +69,12 @@
|
||||
"Label Set: {name}": "Farbmarkierungssatz: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt; Index-Warnung: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom-Import fehlgeschlagen: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom-Prüfung fehlgeschlagen: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.",
|
||||
"Matches: {}": "Treffer: {}",
|
||||
"Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%",
|
||||
@@ -86,11 +92,13 @@
|
||||
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
|
||||
"Quick Develop: {name}": "Schnellentwicklung: {name}",
|
||||
"Rated {}": "Bewertet mit {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}",
|
||||
"Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}",
|
||||
"Reading photos… {} of {}": "Fotos werden gelesen… {} von {}",
|
||||
"Redo {}": "Wiederholen: {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?",
|
||||
"Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen",
|
||||
"Removed from {}": "Entfernt von {}",
|
||||
"Removed {what} from {name}": "{what} aus {name} entfernt",
|
||||
"Rename {n} Photos…": "{n} Fotos umbenennen…",
|
||||
"Rename “{name}”": "„{name}“ umbenennen",
|
||||
@@ -99,6 +107,7 @@
|
||||
"Replace {} with:": "{} ersetzen durch:",
|
||||
"Reset {name}": "{name} zurücksetzen",
|
||||
"Reset {} Channel": "Kanal {} zurücksetzen",
|
||||
"Restore {}": "{} wiederherstellen",
|
||||
"Restore {} Photo{}": "{} Foto{} wiederherstellen",
|
||||
"Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden",
|
||||
"Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.",
|
||||
"Adding": "Wird hinzugefügt",
|
||||
"Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …",
|
||||
"Advanced search…": "Erweiterte Suche…",
|
||||
"After": "Nachher",
|
||||
"After 30 days": "Nach 30 Tagen",
|
||||
@@ -165,6 +166,7 @@
|
||||
"Blue Primary": "Primärfarbe Blau",
|
||||
"Blue Saturation": "Blau – Sättigung",
|
||||
"Blue channel": "Blaukanal",
|
||||
"Blue label": "Blaue Markierung",
|
||||
"Blue/Yellow Fringe": "Blaue/gelbe Farbsäume",
|
||||
"Blurry photos below the score are flagged as rejects.": "Unscharfe Fotos unterhalb des Grenzwerts werden als abgelehnt markiert.",
|
||||
"Boundary Warp": "Randverkrümmung",
|
||||
@@ -208,6 +210,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Unzulässige Zeichen in Dateinamen (/ \\ : * ? \" < > |) werden zu -; ein vorhandener Name erhält -1, -2….",
|
||||
"Check All": "Alle auswählen",
|
||||
"Check Output Path": "Ausgabepfad prüfen",
|
||||
"Check the files of this type": "Dateien dieses Typs auswählen",
|
||||
"Checking names…": "Namen werden geprüft…",
|
||||
"Choose Another Library…": "Andere Bibliothek auswählen…",
|
||||
"Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen",
|
||||
@@ -229,8 +232,11 @@
|
||||
"Clear Reference": "Referenz entfernen",
|
||||
"Clear all": "Alles löschen",
|
||||
"Clear form": "Formular leeren",
|
||||
"Clear rating": "Bewertung entfernen",
|
||||
"Clear rating and advance": "Bewertung entfernen und weiter",
|
||||
"Cleared History": "Verlauf gelöscht",
|
||||
"Click Zoom Ratio": "Zoomfaktor beim Klicken",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Auf ein Tastenkürzel klicken, dann die neuen Tasten drücken (Esc bricht ab).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicke auf einen Platzhalter, um ihn einzufügen. Die Beispiele gelten für IMG_0042.CR3, aufgenommen am 14. Januar 2026 um 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Klicke auf Fotos im Raster, um das Stichwort umzuschalten; Esc beendet den Vorgang",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Objekt zum Auswählen anklicken · ⌥-Klick lässt einen Teil aus",
|
||||
@@ -511,6 +517,7 @@
|
||||
"File Size": "Dateigröße",
|
||||
"File Type": "Dateityp",
|
||||
"File name": "Dateiname",
|
||||
"File types:": "Dateitypen:",
|
||||
"Filename": "Dateiname",
|
||||
"Fill": "Ausfüllen",
|
||||
"Fill Edges": "Ränder füllen",
|
||||
@@ -536,6 +543,7 @@
|
||||
"Fit to width": "An Breite anpassen",
|
||||
"Fit visible": "Sichtbaren Bereich einpassen",
|
||||
"Five stars": "Fünf Sterne",
|
||||
"Fixed keys": "Feste Tasten",
|
||||
"Flag": "Markierung",
|
||||
"Flag as Pick": "Als ausgewählt markieren",
|
||||
"Flag as Reject": "Als abgelehnt markieren",
|
||||
@@ -598,6 +606,7 @@
|
||||
"Green Primary": "Primärfarbe Grün",
|
||||
"Green Saturation": "Grün – Sättigung",
|
||||
"Green channel": "Grünkanal",
|
||||
"Green label": "Grüne Markierung",
|
||||
"Grid": "Raster",
|
||||
"Grid Info": "Rasterinformationen",
|
||||
"Gritty Street": "Raue Straße",
|
||||
@@ -658,6 +667,8 @@
|
||||
"Image": "Bild",
|
||||
"Import": "Importieren",
|
||||
"Import Date": "Importdatum",
|
||||
"Import Lightroom Catalog": "Lightroom-Katalog importieren",
|
||||
"Import Lightroom Catalog…": "Lightroom-Katalog importieren …",
|
||||
"Import Photos": "Fotos importieren",
|
||||
"Import Photos…": "Fotos importieren…",
|
||||
"Import Point Curve Presets": "Punktkurvenvorgaben importieren",
|
||||
@@ -677,7 +688,10 @@
|
||||
"Imported": "Importiert",
|
||||
"Imported Photos": "Importierte Fotos",
|
||||
"Importing": "Wird importiert",
|
||||
"Importing Lightroom Catalog": "Lightroom-Katalog wird importiert",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "In Fotoraster und quadratischem Raster: als ausgewählt markieren und weiter",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen",
|
||||
"In Recently Deleted": "In „Zuletzt gelöscht“",
|
||||
"In library": "In Bibliothek",
|
||||
"In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert",
|
||||
@@ -693,7 +707,9 @@
|
||||
"Info Overlay: File": "Infoüberlagerung: Datei",
|
||||
"Info overlay": "Infoüberlagerung",
|
||||
"Insert at the cursor": "An Cursorposition einfügen",
|
||||
"Inspect Lightroom Catalog": "Lightroom-Katalog prüfen",
|
||||
"Inspect Photo": "Foto untersuchen",
|
||||
"Inspecting Lightroom Catalog": "Lightroom-Katalog wird geprüft",
|
||||
"Instant ’70s": "Sofortbild der 70er",
|
||||
"Interface": "Oberfläche",
|
||||
"Interface language": "Sprache der Oberfläche",
|
||||
@@ -760,6 +776,9 @@
|
||||
"LightCraft on GitHub": "LightCraft auf GitHub",
|
||||
"LightCraft website": "LightCraft-Website",
|
||||
"LightCraft — Second Window": "LightCraft — zweites Fenster",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic-Katalog",
|
||||
"Lightroom import cancelled": "Lightroom-Import abgebrochen",
|
||||
"Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen",
|
||||
"Lights": "Helle Mitteltöne",
|
||||
"Limit file size (KB, 0 = off)": "Dateigröße begrenzen (KB, 0 = aus)",
|
||||
"Line delta": "Zeilendifferenz",
|
||||
@@ -919,6 +938,7 @@
|
||||
"No filter presets yet": "Noch keine Filtervorgaben",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).",
|
||||
"No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.",
|
||||
"No matching commands": "Keine passenden Befehle",
|
||||
"No matching photos": "Keine passenden Fotos",
|
||||
"No metadata found": "Keine Metadaten gefunden",
|
||||
"No model credits were built into this copy.": "Diese Version enthält keine Modellangaben.",
|
||||
@@ -1046,6 +1066,7 @@
|
||||
"Presets (Shift+P)": "Vorgaben (Umschalt+P)",
|
||||
"Presets and profiles": "Vorgaben und Profile",
|
||||
"Presets ▾": "Vorgaben ⌄",
|
||||
"Press keys…": "Tasten drücken …",
|
||||
"Preview": "Vorschau",
|
||||
"Preview Build Progress": "Fortschritt der Vorschauerstellung",
|
||||
"Preview only": "Nur Vorschau",
|
||||
@@ -1090,6 +1111,16 @@
|
||||
"Radius": "Radius",
|
||||
"Random": "Zufällig",
|
||||
"Range": "Bereich",
|
||||
"Rate ★": "Mit ★ bewerten",
|
||||
"Rate ★ and advance": "Mit ★ bewerten und weiter",
|
||||
"Rate ★★": "Mit ★★ bewerten",
|
||||
"Rate ★★ and advance": "Mit ★★ bewerten und weiter",
|
||||
"Rate ★★★": "Mit ★★★ bewerten",
|
||||
"Rate ★★★ and advance": "Mit ★★★ bewerten und weiter",
|
||||
"Rate ★★★★": "Mit ★★★★ bewerten",
|
||||
"Rate ★★★★ and advance": "Mit ★★★★ bewerten und weiter",
|
||||
"Rate ★★★★★": "Mit ★★★★★ bewerten",
|
||||
"Rate ★★★★★ and advance": "Mit ★★★★★ bewerten und weiter",
|
||||
"Rating": "Bewertung",
|
||||
"Rating cleared": "Bewertung entfernt",
|
||||
"Ratings & flags": "Bewertungen und Markierungen",
|
||||
@@ -1103,6 +1134,7 @@
|
||||
"Read mode": "Lesemodus",
|
||||
"Read the SAM License": "SAM-Lizenz lesen",
|
||||
"Reading": "Wird gelesen",
|
||||
"Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …",
|
||||
"Reading folder…": "Ordner wird gelesen…",
|
||||
"Real name": "Echter Name",
|
||||
"Recent": "Zuletzt verwendet",
|
||||
@@ -1116,6 +1148,7 @@
|
||||
"Red Primary": "Primärfarbe Rot",
|
||||
"Red Saturation": "Rot – Sättigung",
|
||||
"Red channel": "Rotkanal",
|
||||
"Red label": "Rote Markierung",
|
||||
"Red/Cyan Fringe": "Rote/cyanfarbene Farbsäume",
|
||||
"Redo": "Wiederholen",
|
||||
"Reference": "Referenz",
|
||||
@@ -1156,6 +1189,7 @@
|
||||
"Remove from Target Album": "Aus Zielalbum entfernen",
|
||||
"Remove from survey": "Aus Übersicht entfernen",
|
||||
"Remove location info": "Ortsinformationen entfernen",
|
||||
"Remove shortcut": "Tastenkürzel entfernen",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Diesen Gesichtsrahmen entfernen (rückgängig über Bearbeiten → Rückgängig)",
|
||||
"Remove this filter": "Diesen Filter entfernen",
|
||||
"Remove this group": "Diese Gruppe entfernen",
|
||||
@@ -1182,7 +1216,9 @@
|
||||
"Rendering…": "Wird gerendert…",
|
||||
"Reorder Masks": "Masken umordnen",
|
||||
"Reset": "Zurücksetzen",
|
||||
"Reset All": "Alle zurücksetzen",
|
||||
"Reset All Curves": "Alle Kurven zurücksetzen",
|
||||
"Reset All Keyboard Shortcuts": "Alle Tastenkürzel zurücksetzen",
|
||||
"Reset Before to Import State": "Vorher auf Importzustand zurücksetzen",
|
||||
"Reset Control": "Regler zurücksetzen",
|
||||
"Reset Crop": "Zuschnitt zurücksetzen",
|
||||
@@ -1197,6 +1233,7 @@
|
||||
"Resize to": "Größe ändern auf",
|
||||
"Resolution (ppi)": "Auflösung (ppi)",
|
||||
"Restore": "Wiederherstellen",
|
||||
"Restore (no shortcut)": "Wiederherstellen (kein Tastenkürzel)",
|
||||
"Restore Library from Backup…": "Bibliothek aus Sicherung wiederherstellen…",
|
||||
"Restore Version": "Version wiederherstellen",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Wiederherstellen behält ihre Bearbeitungen; „Neu importieren“ löscht zuerst den alten Eintrag.",
|
||||
@@ -1243,6 +1280,7 @@
|
||||
"Save as set": "Als Satz speichern",
|
||||
"Save as…": "Speichern unter…",
|
||||
"Save preset": "Vorgabe speichern",
|
||||
"Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …",
|
||||
"Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine",
|
||||
"Scale": "Skalierung",
|
||||
"Scan failed": "Durchsuchen fehlgeschlagen",
|
||||
@@ -1251,6 +1289,7 @@
|
||||
"Screen": "Bildschirm",
|
||||
"Search": "Suche",
|
||||
"Search Photos": "Fotos suchen",
|
||||
"Search commands or keys": "Befehle oder Tasten suchen",
|
||||
"Search…": "Suchen…",
|
||||
"Seasons": "Jahreszeiten",
|
||||
"Second Window": "Zweites Fenster",
|
||||
@@ -1281,6 +1320,7 @@
|
||||
"Set Develop Values": "Entwicklungswerte festlegen",
|
||||
"Set Flag": "Markierung festlegen",
|
||||
"Set Image Zoom and Pan": "Bildzoom und -ausschnitt festlegen",
|
||||
"Set Keyboard Shortcut": "Tastenkürzel festlegen",
|
||||
"Set Mask Adjustments": "Maskenkorrekturen festlegen",
|
||||
"Set Point Curve": "Punktkurve festlegen",
|
||||
"Set Profile": "Profil festlegen",
|
||||
@@ -1331,6 +1371,7 @@
|
||||
"Show Pins": "Pins anzeigen",
|
||||
"Show Source": "Quelle anzeigen",
|
||||
"Show Thumbnails": "Miniaturbilder anzeigen",
|
||||
"Show commands without a shortcut": "Befehle ohne Tastenkürzel anzeigen",
|
||||
"Show destination gamut warning": "Zielfarbraumwarnung anzeigen",
|
||||
"Show display gamut warning": "Bildschirmfarbraumwarnung anzeigen",
|
||||
"Show file names": "Dateinamen anzeigen",
|
||||
@@ -1441,6 +1482,7 @@
|
||||
"Text direction": "Textrichtung",
|
||||
"Texture": "Struktur",
|
||||
"That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.",
|
||||
"That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.",
|
||||
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek",
|
||||
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält",
|
||||
@@ -1455,7 +1497,6 @@
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
|
||||
"Thirds": "Drittel",
|
||||
"This Computer": "Dieser Computer",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Diese Version enthält noch keine Downloadquelle für das Modell: Lege die Dateien selbst im obigen Ordner ab (siehe docs/ai-masks.md).",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.",
|
||||
"This photo can't be opened": "Dieses Foto kann nicht geöffnet werden",
|
||||
"Threshold": "Schwellenwert",
|
||||
@@ -1483,6 +1524,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "Aktiviere „Unterordner einbeziehen“ oder wähle unter „Lokal“ einen anderen Ordner",
|
||||
"Type": "Typ",
|
||||
"Uncheck All": "Alle abwählen",
|
||||
"Uncheck the files of this type": "Auswahl der Dateien dieses Typs aufheben",
|
||||
"Undo": "Rückgängig",
|
||||
"Unedited": "Unbearbeitet",
|
||||
"Unedited raws": "Unbearbeitete RAW-Fotos",
|
||||
@@ -1561,6 +1603,7 @@
|
||||
"Yellow Hue": "Gelb – Farbton",
|
||||
"Yellow Luminance": "Gelb – Luminanz",
|
||||
"Yellow Saturation": "Gelb – Sättigung",
|
||||
"Yellow label": "Gelbe Markierung",
|
||||
"Yes": "Ja",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Du kannst dieses Fenster schließen: Der Download läuft weiter und wird nach Unterbrechungen fortgesetzt.",
|
||||
"Your Name": "Dein Name",
|
||||
@@ -1748,5 +1791,8 @@
|
||||
"Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera",
|
||||
"Captured from": "Aufgenommen ab",
|
||||
"Captured until": "Aufgenommen bis",
|
||||
"Only": "Nur diese"
|
||||
"Only": "Nur diese",
|
||||
"How to install the model": "So installierst du das Modell",
|
||||
"Install the SAM 3 Model": "SAM-3-Modell installieren",
|
||||
"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.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen."
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
" ({unsure} with several look-alike files: use Locate)": " ({unsure} con varios archivos parecidos: usar Localizar)",
|
||||
" ({} in Recently Deleted)": " ({} en Eliminadas recientemente)",
|
||||
" · {:.0}% clipped": " · {:.0}% recortado",
|
||||
" · {failed} couldn't be rendered": " · no se pudieron renderizar: {failed}",
|
||||
" · {skipped} skipped (file exists)": " · omitidas: {skipped} (el archivo existe)",
|
||||
" · {} duplicate{} skipped": " · {0} duplicado{1} omitido{1}",
|
||||
" · {} failed: {}": " · {} con errores: {}",
|
||||
" · {} moved": " · movidas: {}",
|
||||
" · {} not readable": " · ilegibles: {}",
|
||||
" · {} original{} left at the source": " · originales conservados en el origen: {}{:.0}",
|
||||
" · {} restored from Recently Deleted": " · restauradas desde Eliminadas recientemente: {}",
|
||||
" — not carried over: {}{}": " — no transferido: {}{}",
|
||||
"+{} more": "+{} más",
|
||||
", {failed} file{} not readable": ", {failed} archivo{0} ilegible{0}",
|
||||
"; {kept} already had a location": "; ya tenían ubicación: {kept}",
|
||||
"; {outside} outside its time range": "; fuera de su intervalo de tiempo: {outside}",
|
||||
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commits, +{} / −{} líneas (Δ {}), +{} / −{} recursos binarios, {} – {}",
|
||||
"Add {local_n} to My Photos": "Añadir {local_n} a Mis fotos",
|
||||
"Add {} Photo{}": "Añadir {} foto{}",
|
||||
"Add {} Photo{}, restore {}": "Añadir {} foto{}, restaurar {}",
|
||||
"Add “{}” to Favorites": "Añadir “{}” a Favoritos",
|
||||
"Added {n} photo{} to My Photos": "{n} foto{0} añadida{0} a Mis fotos",
|
||||
"Added {n} photo{} to “{}”": "{n} foto{0} añadida{0} a “{1}”",
|
||||
"Added {what} to {name}": "{what} añadido a {name}",
|
||||
"Adding photos… {} of {}": "Añadiendo fotos… {} de {}",
|
||||
"All {n} selected photos move by the same amount.": "Las {n} fotos seleccionadas se desplazan la misma cantidad.",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho ▸ Restaurar, o Eliminar definitivamente para importar de nuevo",
|
||||
"Already in the library: {} photo{} selected in All Photos": "Ya en la biblioteca: {0} foto{1} seleccionada{1} en Todas las fotos",
|
||||
"Auto ({})": "Automático ({})",
|
||||
"Auto Import: added {} photo{}": "Importación automática: {0} foto{1} añadida{1}",
|
||||
"Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos",
|
||||
"Automatic ({} MB)": "Automático ({} MB)",
|
||||
"Build 1:1 Previews ({scope})": "Generar vistas previas 1:1 ({scope})",
|
||||
"Build Smart Previews ({scope})": "Generar vistas previas inteligentes ({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})",
|
||||
"Building previews for {} photos…": "Generando vistas previas de {} fotos…",
|
||||
"Click {}:1": "Clic {}:1",
|
||||
"Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}",
|
||||
"Create {n} Virtual Copies": "Crear {n} copias virtuales",
|
||||
"Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}",
|
||||
"Decoder: {reason}": "Decodificador: {reason}",
|
||||
"Delete \"{label}\"": "Eliminar \"{label}\"",
|
||||
"Delete \"{label}\" (and the mask)": "Eliminar \"{label}\" (y la máscara)",
|
||||
"Delete {n} Photos": "Eliminar {n} fotos",
|
||||
"Delete “{current}”": "Eliminar “{current}”",
|
||||
"Discard Smart Previews ({scope})": "Descartar vistas previas inteligentes ({scope})",
|
||||
"Done ({what}): {done} photo{plural}": "Listo ({what}): {done} foto{plural}",
|
||||
"Duplicate \"{label}\"": "Duplicar \"{label}\"",
|
||||
"Example: {example}": "Ejemplo: {example}",
|
||||
"Exported {n} photo{}": "{n} foto{0} exportada{0}",
|
||||
"Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",
|
||||
"Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}",
|
||||
"Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}",
|
||||
"Exporting {} of {total}": "Exportando {} de {total}",
|
||||
"Filter bar — {active} active filter{}": "Barra de filtros: {active} filtro{0} activo{0}",
|
||||
"Focus {:.0}": "Enfoque {:.0}",
|
||||
"Folder “{}” (and its subfolders)": "Carpeta “{}” (y sus subcarpetas)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "Fotos que faltaban encontradas: {n}{:.0}; siguen faltando: {left}{unsure}",
|
||||
"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}",
|
||||
"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}",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Perfiles importados: {profiles}{:.0} (Explorador de perfiles ▸ sus grupos)",
|
||||
"Imported {} photo{}": "{0} foto{1} importada{1}",
|
||||
"Insert at the cursor (also written {})": "Insertar en el cursor (también se escribe {})",
|
||||
"Label Set: {name}": "Conjunto de etiquetas: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft no pudo abrir su ventana: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft no pudo abrir su ventana: {e}\n\nLos detalles están en el archivo de registro: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo de Lightroom inspeccionado: {photos} fotos, {collections} colecciones, {missing} no encontradas",
|
||||
"Lightroom import complete: {imported} photos added": "Importación de Lightroom completada: {imported} fotos añadidas",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Importación de Lightroom completada: {imported} fotos añadidas, {failed} con errores",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importación de Lightroom completada: {imported} fotos añadidas; aviso del índice: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Error al importar de Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Error al inspeccionar Lightroom: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fuentes: {} (OFL). Iconos: originales.",
|
||||
"Matches: {}": "Coincidencias: {}",
|
||||
"Merging… {stage} {:.0}%": "Combinando… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "¿Mover {what} a Eliminadas recientemente?",
|
||||
"Named ({named_n})": "Con nombre ({named_n})",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "Pintando “{k}”: hacer clic en las fotos para añadirla o quitarla · Esc para terminar",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "Pegar en {n} foto{}: solo se pegan los ajustes copiados",
|
||||
"Preset: {name}": "Ajuste preestablecido: {name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "Solo vista previa: editando el JPEG incrustado de la cámara ({})",
|
||||
"Preview only — {}": "Solo vista previa: {}",
|
||||
"Previews ready for {done} photo{plural}": "Vistas previas listas para {done} foto{plural}",
|
||||
"Profile: {}": "Perfil: {}",
|
||||
"Proof Copy ({})": "Copia de prueba ({})",
|
||||
"Proof Preview · {}": "Vista previa de prueba · {}",
|
||||
"Quick Develop · {n} photos": "Revelado rápido · {n} fotos",
|
||||
"Quick Develop: {name}": "Revelado rápido: {name}",
|
||||
"Rated {}": "Valorada con {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Leyendo catálogo de Lightroom… {done} de {total}",
|
||||
"Reading photos… {done} of {total}": "Leyendo fotos… {done} de {total}",
|
||||
"Reading photos… {} of {}": "Leyendo fotos… {} de {}",
|
||||
"Redo {}": "Rehacer {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "¿Quitar “{name}” y sus {count} foto{} de la biblioteca?",
|
||||
"Remove “{}” from Favorites": "Quitar “{}” de Favoritos",
|
||||
"Removed from {}": "Quitado de {}",
|
||||
"Removed {what} from {name}": "{what} quitado de {name}",
|
||||
"Rename {n} Photos…": "Cambiar nombre de {n} fotos…",
|
||||
"Rename “{name}”": "Cambiar nombre de “{name}”",
|
||||
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Cambia el nombre de “{from}” en {n} foto{} (también las palabras clave que dependen de ella). | separa niveles, p. ej. Viajes|Italia. Un nombre existente combina las dos.",
|
||||
"Rendering on the CPU: {why}": "Renderizado en la CPU: {why}",
|
||||
"Replace {} with:": "Reemplazar {} por:",
|
||||
"Reset {name}": "Restablecer {name}",
|
||||
"Reset {} Channel": "Restablecer canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{}",
|
||||
"Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).",
|
||||
"Show {hidden} hidden location{}": "Mostrar ubicaciones ocultas: {hidden}{:.0}",
|
||||
"Showing {n} of {}": "Mostrando {n} de {}",
|
||||
"Smart album: {}": "Álbum inteligente: {}",
|
||||
"Tagged {n} photo{} from the tracklog": "{n} foto{0} ubicada{0} desde el registro de ruta",
|
||||
"The SAM 3 download failed: {e}": "Error en la descarga de SAM 3: {e}",
|
||||
"Undo {}": "Deshacer {}",
|
||||
"Unknown tag{s} {} stay{} as typed (see Tags)": "Etiqueta{s} desconocida{s} {} sin cambios{:.0} (ver Etiquetas)",
|
||||
"Updated “{name}”": "“{name}” actualizado",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versión {}: biblioteca de fotos y revelador RAW de desarrollo independiente (clean-room) en Rust puro.",
|
||||
"Version: {}": "Versión: {}",
|
||||
"Virtual copy “{name}” of {of}": "Copia virtual “{name}” de {of}",
|
||||
"Working on {what} for {} photos…": "Procesando {what} para {} fotos…",
|
||||
"none (CPU rendering): {why}": "ninguna (renderizado por CPU): {why}",
|
||||
"the {n} selected photos": "las {n} fotos seleccionadas",
|
||||
"the {} photos in view": "las {} fotos de la vista",
|
||||
"these {count} photos": "estas {count} fotos",
|
||||
"{count} smart preview{} · {:.1} MB": "{count} vista{0} previa{0} inteligente{0} · {1:.1} MB",
|
||||
"{f} folders (and their subfolders)": "{f} carpetas (y sus subcarpetas)",
|
||||
"{label} Grading": "Gradación de {label}",
|
||||
"{label} {d:+} on every selected photo": "{label} {d:+} en cada foto seleccionada",
|
||||
"{label} {index} of {n}": "{label} {index} de {n}",
|
||||
"{label}, {n} photos": "{label}, {n} fotos",
|
||||
"{matching} of {t} photos": "{matching} de {t} fotos",
|
||||
"{matching} photos": "{matching} fotos",
|
||||
"{mode} spot {} of {}": "{mode}: mancha {} de {}",
|
||||
"{month}": "{month:02}",
|
||||
"{month} {year}": "{month:02}/{year}",
|
||||
"{msg} ({failed} failed)": "{msg} ({failed} con errores)",
|
||||
"{name} Label": "Etiqueta {name}",
|
||||
"{name} Off": "{name}: desactivado",
|
||||
"{name} On": "{name}: activado",
|
||||
"{name} opened for editing; it is stacked with the original": "{name} abierta para editar; está apilada con el original",
|
||||
"{name} — double-click to reset it": "{name}: doble clic para restablecer",
|
||||
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Cambios que no se pudieron escribir en el disco: {n}{0:.0}: {e}\nSi se sale ahora, se perderán.{1:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Cambios guardados en memoria, pero no escritos en el disco: {n}{0:.0}: {e}\nLightCraft vuelve a intentarlo automáticamente; si se sale ahora, se perderán.{1:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Cambios guardados solo en memoria: {n}{:.0}: {e}; LightCraft volverá a intentarlo",
|
||||
"{n} found · {sel} selected": "{n} encontradas · {sel} seleccionadas",
|
||||
"{n} photos": "{n} fotos",
|
||||
"{n} photos in {} are hidden by the filters above": "Los filtros de arriba ocultan {n} fotos en {}",
|
||||
"{n} photo{}": "{n} foto{}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos",
|
||||
"{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos",
|
||||
"{pid} (missing)": "{pid} (falta)",
|
||||
"{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{verb} {n} Photo{}": "{verb} {n} foto{}",
|
||||
"{weekday}, {day}": "{weekday}, {day}",
|
||||
"{weekday}, {day} {month} {year}": "{weekday}, {day}/{month:02}/{year}",
|
||||
"{what} merge added": "Combinación {what} añadida",
|
||||
"{what} merge failed: {e}": "Error en la combinación {what}: {e}",
|
||||
"{year}": "{year}",
|
||||
"{{date:…}} directives: {}": "Directivas de {{date:…}}: {}",
|
||||
"{} and higher": "{} o más",
|
||||
"{} contributors · {} commits": "{} colaboradores · {} commits",
|
||||
"{} failed": "Con errores: {}",
|
||||
"{} files": "{} archivos",
|
||||
"{} files and {f} folder{}": "{} archivos y {f} carpeta{}",
|
||||
"{} item{}": "{0} elemento{1}",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guías: arrastrar a lo largo de líneas que deban ser verticales u horizontales.",
|
||||
"{} photos": "{} fotos",
|
||||
"{} photos · exposures {} EV": "{} fotos · exposiciones {} EV",
|
||||
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
|
||||
"{} photo{} not readable": "{0} foto{1} ilegible{1}",
|
||||
"{} sample{}": "{} muestra{}",
|
||||
"{} sec": "{} s",
|
||||
"{} spot(s) on this photo": "{} mancha(s) en esta foto",
|
||||
"{} — show their photos": "{}: mostrar sus fotos",
|
||||
"· {dups} already in the library": "· ya en la biblioteca: {dups}",
|
||||
"· {n} photo{}": "· {n} foto{}",
|
||||
"… and {more} more": "… y {more} más",
|
||||
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft aún no puede decodificar esta variante RAW ({}). Se está editando la vista previa JPEG incrustada de la cámara, que ya incluye el estilo de imagen de la cámara (p. ej. Monocromo) y el balance de blancos.",
|
||||
"Filtered: {label}": "Filtrado: {label}",
|
||||
"{n} similar photo{} · View ▸ Clear Filters to see all": "Fotos similares: {n}{:.0} · Vista ▸ Borrar filtros para ver todas",
|
||||
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) de nuevo",
|
||||
"{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)",
|
||||
", {} star{}": ", {} estrella{}",
|
||||
", {} label": ", etiqueta {}"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -50,13 +50,16 @@
|
||||
"Proof Preview · {}": "校正プレビュー・{}",
|
||||
"Quick Develop · {n} photos": "クイック現像・{n}枚",
|
||||
"Rated {}": "評価:{}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lightroom カタログを読み取り中… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
|
||||
"Redo {}": "やり直す:{}",
|
||||
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
|
||||
"Removed from {}": "{} から削除しました",
|
||||
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
|
||||
"Rename “{name}”": "「{name}」の名前を変更",
|
||||
"Rendering on the CPU: {why}": "CPUで描画:{why}",
|
||||
"Reset {} Channel": "{}チャンネルをリセット",
|
||||
"Restore {}": "{} に戻す",
|
||||
"Restore {} Photo{}": "{}枚の写真を復元{:.0}",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
|
||||
@@ -114,6 +117,12 @@
|
||||
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
|
||||
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom カタログを確認しました:写真 {photos} 枚、コレクション {collections} 件、見つからないファイル {missing} 件",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom の読み込みが完了しました:{imported} 枚の写真を追加",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom の読み込みが完了しました:{imported} 枚を追加、{failed} 枚が失敗",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom の読み込みが完了しました:{imported} 枚を追加。インデックスの警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom の読み込みに失敗しました:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom の確認に失敗しました:{e}{warning}",
|
||||
"Label Set: {name}": "ラベルセット:{name}",
|
||||
"Quick Develop: {name}": "クイック現像: {name}",
|
||||
"Reset {name}": "{name}をリセット",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Add/Subtract/Intersect Mask": "マスクを追加/減算/交差",
|
||||
"Added to photos you import that don't already have it.": "読み込む写真に、この情報がない場合だけ追加します",
|
||||
"Adding": "追加中",
|
||||
"Adding Lightroom photos…": "Lightroom の写真を追加中…",
|
||||
"After": "編集後",
|
||||
"After 30 days": "30日後",
|
||||
"After 7 days": "7日後",
|
||||
@@ -149,6 +150,7 @@
|
||||
"Blue Primary": "ブルー原色",
|
||||
"Blue Saturation": "ブルーの彩度",
|
||||
"Blue channel": "ブルーチャンネル",
|
||||
"Blue label": "ブルーラベル",
|
||||
"Blue/Yellow Fringe": "青/黄のフリンジ",
|
||||
"Blurry photos below the score are flagged as rejects.": "基準値未満のぼけた写真に不採用フラグを付けます",
|
||||
"Boundary Warp": "境界の変形",
|
||||
@@ -190,6 +192,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ファイル名に使えない文字(/ \\ : * ? \" < > |)は - に置き換え、既存の名前と重なる場合は -1、-2… を付けます",
|
||||
"Check All": "すべて選択",
|
||||
"Check Output Path": "書き出し先のパスを確認",
|
||||
"Check the files of this type": "この種類のファイルをすべてチェック",
|
||||
"Checking names…": "名前を確認中…",
|
||||
"Choose Edit Settings to Copy": "コピーする編集設定を選択",
|
||||
"Choose Edit Settings to Copy…": "コピーする編集設定を選択…",
|
||||
@@ -209,8 +212,11 @@
|
||||
"Clear Quick Collection": "クイックコレクションを空にする",
|
||||
"Clear Reference": "参照写真を解除",
|
||||
"Clear all": "すべて解除",
|
||||
"Clear rating": "評価を解除",
|
||||
"Clear rating and advance": "評価を解除して次へ",
|
||||
"Cleared History": "履歴を消去しました",
|
||||
"Click Zoom Ratio": "クリック時の拡大率",
|
||||
"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.": "タグをクリックすると挿入できます。例は2026年1月14日05:58:48撮影のIMG_0042.CR3です",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "グリッドの写真をクリックしてキーワードを付け外し(Escで終了)",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "オブジェクトをクリックして選択(⌥クリックで一部を除外)",
|
||||
@@ -458,6 +464,7 @@
|
||||
"File Path": "ファイルの場所",
|
||||
"File Size": "ファイルサイズ",
|
||||
"File Type": "ファイルの種類",
|
||||
"File types:": "ファイルの種類:",
|
||||
"Filename": "ファイル名",
|
||||
"Fill": "画面いっぱい",
|
||||
"Film": "フィルム",
|
||||
@@ -466,6 +473,7 @@
|
||||
"First": "最初",
|
||||
"First commit": "最初のコミット",
|
||||
"Five stars": "星5つ",
|
||||
"Fixed keys": "固定キー",
|
||||
"Flagged as Pick": "採用フラグ付き",
|
||||
"Flagged as Reject": "不採用フラグ付き",
|
||||
"Focal Length": "焦点距離",
|
||||
@@ -483,6 +491,7 @@
|
||||
"Golden Glow": "ゴールデングロー",
|
||||
"Golden Hour": "ゴールデンアワー",
|
||||
"Grab bag": "名前の一覧",
|
||||
"Green label": "グリーンラベル",
|
||||
"Gritty Street": "ざらついたストリート",
|
||||
"Group by Date: Automatic": "日付でグループ化:自動",
|
||||
"Group name": "グループ名",
|
||||
@@ -501,17 +510,24 @@
|
||||
"Hours": "時間",
|
||||
"ISO Speed": "ISO感度",
|
||||
"ISO speed": "ISO感度",
|
||||
"Import Lightroom Catalog": "Lightroom カタログを読み込む",
|
||||
"Import Lightroom Catalog…": "Lightroom カタログを読み込む…",
|
||||
"Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…",
|
||||
"Import Profiles": "プロファイルを読み込む",
|
||||
"Import as new": "新規として読み込む",
|
||||
"Imported": "読み込み済み",
|
||||
"Importing Lightroom Catalog": "Lightroom カタログを読み込み中",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "写真グリッドと正方形グリッドでは:採用フラグを付けて次へ",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "写真グリッドと正方形グリッドでは:スタックを展開または折りたたむ",
|
||||
"Increase Brush Feather": "ブラシのぼかしを大きく",
|
||||
"Increase Brush Size": "ブラシのサイズを大きく",
|
||||
"Info Overlay Off": "情報オーバーレイなし",
|
||||
"Info Overlay: Exposure": "情報オーバーレイ:露出",
|
||||
"Info Overlay: File": "情報オーバーレイ:ファイル",
|
||||
"Insert at the cursor": "カーソル位置に挿入",
|
||||
"Inspect Lightroom Catalog": "Lightroom カタログを確認",
|
||||
"Inspect Photo": "写真を調べる",
|
||||
"Inspecting Lightroom Catalog": "Lightroom カタログを確認中",
|
||||
"Instant ’70s": "インスタント’70s",
|
||||
"Invert Component": "構成要素を反転",
|
||||
"JPEG (Large)": "JPEG(大)",
|
||||
@@ -532,6 +548,9 @@
|
||||
"Library saved": "ライブラリを保存しました",
|
||||
"Licence:": "ライセンス:",
|
||||
"LightCraft could not start": "LightCraftを起動できませんでした",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic カタログ",
|
||||
"Lightroom import cancelled": "Lightroom の読み込みをキャンセルしました",
|
||||
"Lightroom inspection cancelled": "Lightroom の確認をキャンセルしました",
|
||||
"Line delta": "行数の増減",
|
||||
"Linear": "リニア",
|
||||
"Lines +/−": "行数 +/−",
|
||||
@@ -588,6 +607,7 @@
|
||||
"Next Photo": "次の写真",
|
||||
"Night Blue": "ナイトブルー",
|
||||
"No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません",
|
||||
"No matching commands": "一致するコマンドはありません",
|
||||
"No model credits were built into this copy.": "このビルドにはモデルのクレジットが含まれていません",
|
||||
"No named people in these photos": "これらの写真に名前付きの人物はいません",
|
||||
"No named people yet": "名前付きの人物はまだいません",
|
||||
@@ -617,6 +637,7 @@
|
||||
"Pictures": "ピクチャ",
|
||||
"Prepare AI Masks": "AIマスクを準備",
|
||||
"Presets and profiles": "プリセットとプロファイル",
|
||||
"Press keys…": "キーを押してください…",
|
||||
"Preview Build Progress": "プレビュー作成の進行状況",
|
||||
"Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中",
|
||||
"Previous Photo": "前の写真",
|
||||
@@ -630,12 +651,24 @@
|
||||
"RAW decoding": "RAWデコード",
|
||||
"Radial": "円形",
|
||||
"Random": "ランダム",
|
||||
"Rate ★": "評価 ★",
|
||||
"Rate ★ and advance": "評価 ★ にして次へ",
|
||||
"Rate ★★": "評価 ★★",
|
||||
"Rate ★★ and advance": "評価 ★★ にして次へ",
|
||||
"Rate ★★★": "評価 ★★★",
|
||||
"Rate ★★★ and advance": "評価 ★★★ にして次へ",
|
||||
"Rate ★★★★": "評価 ★★★★",
|
||||
"Rate ★★★★ and advance": "評価 ★★★★ にして次へ",
|
||||
"Rate ★★★★★": "評価 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "評価 ★★★★★ にして次へ",
|
||||
"Rating cleared": "評価を解除しました",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "SAMライセンスを読む",
|
||||
"Reading Lightroom catalog…": "Lightroom カタログを読み取り中…",
|
||||
"Real name": "本名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用したキーワード",
|
||||
"Red label": "レッドラベル",
|
||||
"Refine": "微調整",
|
||||
"Refine AI Mask Detail": "AIマスクの細部を微調整",
|
||||
"Refine Edges": "境界を微調整",
|
||||
@@ -650,6 +683,7 @@
|
||||
"Remove Face Box": "顔の枠を除去",
|
||||
"Remove a filter above, or choose Clear all": "上のフィルターを解除するか、「すべて解除」を選択してください",
|
||||
"Remove from Target Album": "追加先アルバムから除去",
|
||||
"Remove shortcut": "ショートカットを削除",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 ▸ 取り消す」で元に戻せます)",
|
||||
"Rename Component": "構成要素の名前を変更",
|
||||
"Rename Folder": "フォルダ名を変更",
|
||||
@@ -658,6 +692,8 @@
|
||||
"Rename Preview": "名前変更のプレビュー",
|
||||
"Rename Template Tags": "名前変更テンプレートのタグ",
|
||||
"Reorder Masks": "マスクを並べ替え",
|
||||
"Reset All": "すべてリセット",
|
||||
"Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット",
|
||||
"Reshuffle": "シャッフルし直す",
|
||||
"File name": "ファイル名",
|
||||
"Fill Edges": "境界を補完",
|
||||
@@ -1088,6 +1124,7 @@
|
||||
"Resize to": "サイズ変更",
|
||||
"Resolution (ppi)": "解像度(ppi)",
|
||||
"Restore": "復元",
|
||||
"Restore (no shortcut)": "元に戻す(ショートカットなし)",
|
||||
"Restore Version": "バージョンを復元",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "復元すると編集内容が残ります。新規として読み込むと、先に古い記録を削除します。",
|
||||
"Restore them": "復元する",
|
||||
@@ -1129,6 +1166,7 @@
|
||||
"Save as Smart Album…": "スマートアルバムとして保存…",
|
||||
"Save as set": "セットとして保存",
|
||||
"Save preset": "プリセットを保存",
|
||||
"Saving Lightroom import index…": "Lightroom 読み込みインデックスを保存中…",
|
||||
"Scale": "拡大率",
|
||||
"Scan failed": "スキャンに失敗しました",
|
||||
"Scanning": "検索中",
|
||||
@@ -1136,6 +1174,7 @@
|
||||
"Screen": "画面用",
|
||||
"Search": "検索",
|
||||
"Search Photos": "写真を検索",
|
||||
"Search commands or keys": "コマンドまたはキーを検索",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第2ウィンドウ",
|
||||
"Select": "選択",
|
||||
@@ -1165,6 +1204,7 @@
|
||||
"Set Develop Values": "現像設定を変更",
|
||||
"Set Flag": "フラグを設定",
|
||||
"Set Image Zoom and Pan": "画像のズームとパンを設定",
|
||||
"Set Keyboard Shortcut": "キーボードショートカットを設定",
|
||||
"Set Mask Adjustments": "マスクの調整を設定",
|
||||
"Set Point Curve": "ポイントカーブを変更",
|
||||
"Set Profile": "プロファイルを設定",
|
||||
@@ -1214,6 +1254,7 @@
|
||||
"Show Pins": "ピンを表示",
|
||||
"Show Source": "コピー元を表示",
|
||||
"Show Thumbnails": "サムネイルを表示",
|
||||
"Show commands without a shortcut": "ショートカットのないコマンドも表示",
|
||||
"Show destination gamut warning": "出力先の色域警告を表示",
|
||||
"Show display gamut warning": "ディスプレイの色域警告を表示",
|
||||
"Show file names": "ファイル名を表示",
|
||||
@@ -1311,6 +1352,7 @@
|
||||
"Template": "テンプレート",
|
||||
"Text": "文字",
|
||||
"Texture": "テクスチャ",
|
||||
"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 number at the end of the original name (IMG_0042 → 0042)": "元の名前の末尾の番号(IMG_0042 → 0042)",
|
||||
@@ -1319,7 +1361,6 @@
|
||||
"The photo has these settings": "この写真に適用されている設定",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
|
||||
"Thirds": "三分割",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "このビルドにはモデルのダウンロード元がまだありません。上のフォルダにファイルを手動で置いてください(docs/ai-masks.md を参照)",
|
||||
"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": "しきい値",
|
||||
@@ -1343,6 +1384,7 @@
|
||||
"Turn Off": "無効にする",
|
||||
"Turn on Include subfolders, or pick another folder under Local": "サブフォルダを含める設定を有効にするか、ローカルから別のフォルダを選択してください",
|
||||
"Uncheck All": "すべて選択解除",
|
||||
"Uncheck the files of this type": "この種類のファイルのチェックをすべて外す",
|
||||
"Undo": "取り消す",
|
||||
"Unedited": "未編集",
|
||||
"Unedited raws": "未編集のRAW",
|
||||
@@ -1416,6 +1458,7 @@
|
||||
"Yellow Hue": "イエローの色相",
|
||||
"Yellow Luminance": "イエローの輝度",
|
||||
"Yellow Saturation": "イエローの彩度",
|
||||
"Yellow label": "イエローラベル",
|
||||
"Yes": "はい",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "このウィンドウは閉じてもかまいません。ダウンロードは続行され、中断しても再開します",
|
||||
"Your Name": "あなたの名前",
|
||||
@@ -1593,5 +1636,8 @@
|
||||
"≤ at most": "≤ 以下",
|
||||
"≥ at least": "≥ 以上",
|
||||
"日本語": "日本語",
|
||||
"繁體中文(台灣)": "繁體中文(台灣)"
|
||||
"繁體中文(台灣)": "繁體中文(台灣)",
|
||||
"How to install the model": "モデルのインストール方法",
|
||||
"Install the SAM 3 Model": "SAM 3モデルのインストール",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。"
|
||||
}
|
||||
@@ -45,6 +45,12 @@
|
||||
"Imported {n} preset{}": "Importadas {n} predefinição{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível",
|
||||
"Imported {} photo{}": "Importadas {} foto{:.0}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo do Lightroom inspecionado: {photos} fotos, {collections} coleções, {missing} ausentes",
|
||||
"Lightroom import complete: {imported} photos added": "Importação do Lightroom concluída: {imported} fotos adicionadas",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Importação do Lightroom concluída: {imported} fotos adicionadas, {failed} com falha",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importação do Lightroom concluída: {imported} fotos adicionadas; aviso do índice: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Falha na importação do Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Falha na inspeção do Lightroom: {e}{warning}",
|
||||
"Label Set: {name}": "Conjunto de rótulos: {name}",
|
||||
"LightCraft could not open its window: {e}": "O LightCraft não conseguiu abrir sua janela: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
|
||||
@@ -62,16 +68,19 @@
|
||||
"Quick Develop · {n} photos": "Revelação rápida · {n} fotos",
|
||||
"Quick Develop: {name}": "Revelação rápida: {name}",
|
||||
"Rated {}": "Avaliada {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lendo catálogo do Lightroom… {done} de {total}",
|
||||
"Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}",
|
||||
"Reading photos… {} of {}": "Lendo fotos… {} de {}",
|
||||
"Redo {}": "Refazer {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "Remover “{name}” e {count} foto{} da biblioteca?",
|
||||
"Remove “{}” from Favorites": "Remover “{}” dos favoritos",
|
||||
"Removed from {}": "Removido de {}",
|
||||
"Rename {n} Photos…": "Renomear {n} fotos…",
|
||||
"Rename “{name}”": "Renomear “{name}”",
|
||||
"Rendering on the CPU: {why}": "Renderizando na CPU: {why}",
|
||||
"Reset {name}": "Redefinir {name}",
|
||||
"Reset {} Channel": "Redefinir canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{:.0}",
|
||||
"Saved export preset “{}”": "Predefinição de exportação “{}” salva",
|
||||
"Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"Add/Subtract/Intersect Mask": "Adicionar/Subtrair/Interseção de máscara",
|
||||
"Added to photos you import that don't already have it.": "Adicionado às fotos importadas que ainda não têm isso.",
|
||||
"Adding": "Adicionando",
|
||||
"Adding Lightroom photos…": "Adicionando fotos do Lightroom…",
|
||||
"Advanced search…": "Busca avançada…",
|
||||
"After": "Depois",
|
||||
"After 30 days": "Após 30 dias",
|
||||
@@ -153,6 +154,7 @@
|
||||
"Blue Primary": "Azul primário",
|
||||
"Blue Saturation": "Saturação do azul",
|
||||
"Blue channel": "Canal azul",
|
||||
"Blue label": "Rótulo azul",
|
||||
"Blue/Yellow Fringe": "Franja azul/amarela",
|
||||
"Blurry photos below the score are flagged as rejects.": "Fotos desfocadas abaixo da pontuação são marcadas como rejeitadas.",
|
||||
"Boundary Warp": "Deformação de borda",
|
||||
@@ -195,6 +197,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Caracteres não permitidos em nomes de arquivo (/ \\ : * ? \" < > |) viram -; um nome já existente recebe -1, -2….",
|
||||
"Check All": "Marcar todos",
|
||||
"Check Output Path": "Verificar caminho de saída",
|
||||
"Check the files of this type": "Marcar os arquivos deste tipo",
|
||||
"Checking names…": "Verificando nomes…",
|
||||
"Choose Another Library…": "Escolher outra biblioteca…",
|
||||
"Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar",
|
||||
@@ -216,8 +219,11 @@
|
||||
"Clear Reference": "Limpar referência",
|
||||
"Clear all": "Limpar tudo",
|
||||
"Clear form": "Limpar formulário",
|
||||
"Clear rating": "Limpar avaliação",
|
||||
"Clear rating and advance": "Limpar avaliação e avançar",
|
||||
"Cleared History": "Histórico limpo",
|
||||
"Click Zoom Ratio": "Proporção de zoom por clique",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Clique em um atalho e pressione as novas teclas (Esc cancela).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Clique em uma tag para inseri-la. Os exemplos são de IMG_0042.CR3 tirada em 14 de jan. de 2026, 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Clique nas fotos da grade para alternar a palavra-chave; Esc encerra",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Clique no objeto para selecioná-lo · ⌥-clique deixa uma parte de fora",
|
||||
@@ -485,6 +491,7 @@
|
||||
"File Size": "Tamanho do arquivo",
|
||||
"File Type": "Tipo de arquivo",
|
||||
"File name": "Nome do arquivo",
|
||||
"File types:": "Tipos de arquivo:",
|
||||
"Filename": "Nome do arquivo",
|
||||
"Fill": "Preencher",
|
||||
"Fill Edges": "Preencher bordas",
|
||||
@@ -510,6 +517,7 @@
|
||||
"Fit to width": "Ajustar à largura",
|
||||
"Fit visible": "Ajustar ao visível",
|
||||
"Five stars": "Cinco estrelas",
|
||||
"Fixed keys": "Teclas fixas",
|
||||
"Flag": "Marcar",
|
||||
"Flag as Pick": "Marcar como seleção",
|
||||
"Flag as Reject": "Marcar como rejeitada",
|
||||
@@ -572,6 +580,7 @@
|
||||
"Green Primary": "Primária verde",
|
||||
"Green Saturation": "Saturação do verde",
|
||||
"Green channel": "Canal verde",
|
||||
"Green label": "Rótulo verde",
|
||||
"Grid": "Grade",
|
||||
"Grid Info": "Informações da grade",
|
||||
"Gritty Street": "Urbano cru",
|
||||
@@ -623,6 +632,7 @@
|
||||
"Horizontal": "Horizontal",
|
||||
"Horizontal text": "Texto horizontal",
|
||||
"Hours": "Horas",
|
||||
"How to install the model": "Como instalar o modelo",
|
||||
"Hue": "Matiz",
|
||||
"Hue Range": "Faixa de matiz",
|
||||
"ISO Speed": "Sensibilidade ISO",
|
||||
@@ -631,6 +641,8 @@
|
||||
"Image": "Imagem",
|
||||
"Import": "Importar",
|
||||
"Import Date": "Data de importação",
|
||||
"Import Lightroom Catalog": "Importar catálogo do Lightroom",
|
||||
"Import Lightroom Catalog…": "Importar catálogo do Lightroom…",
|
||||
"Import Photos": "Importar fotos",
|
||||
"Import Photos…": "Importar fotos…",
|
||||
"Import Point Curve Presets": "Importar predefinições de curva de pontos",
|
||||
@@ -646,7 +658,10 @@
|
||||
"Imported": "Importados",
|
||||
"Imported Photos": "Fotos importadas",
|
||||
"Importing": "Importando",
|
||||
"Importing Lightroom Catalog": "Importando catálogo do Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "A importação examina a origem em busca de fotos; ela não adiciona a pasta a Local (use Local → Procurar pasta… para trabalhar em uma pasta sem importar).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "Na Grade de fotos e na Grade quadrada: marcar como seleção e avançar",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "Na Grade de fotos e na Grade quadrada: expandir ou recolher a pilha",
|
||||
"In Recently Deleted": "Em Recentemente excluídos",
|
||||
"In library": "Na biblioteca",
|
||||
"In memory — nothing is saved": "Na memória — nada é salvo",
|
||||
@@ -662,7 +677,10 @@
|
||||
"Info Overlay: File": "Sobreposição de informações: arquivo",
|
||||
"Info overlay": "Sobreposição de informações",
|
||||
"Insert at the cursor": "Inserir no cursor",
|
||||
"Inspect Lightroom Catalog": "Inspecionar catálogo do Lightroom",
|
||||
"Inspect Photo": "Inspecionar foto",
|
||||
"Inspecting Lightroom Catalog": "Inspecionando catálogo do Lightroom",
|
||||
"Install the SAM 3 Model": "Instalar o modelo SAM 3",
|
||||
"Instant ’70s": "Instantâneo anos 70",
|
||||
"Interface": "Interface",
|
||||
"Interface language": "Idioma da interface",
|
||||
@@ -727,6 +745,9 @@
|
||||
"LightCraft on GitHub": "LightCraft no GitHub",
|
||||
"LightCraft website": "Site do LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft — segunda janela",
|
||||
"Lightroom Classic Catalog": "Catálogo do Lightroom Classic",
|
||||
"Lightroom import cancelled": "Importação do Lightroom cancelada",
|
||||
"Lightroom inspection cancelled": "Inspeção do Lightroom cancelada",
|
||||
"Lights": "Luzes",
|
||||
"Limit file size (KB, 0 = off)": "Limitar tamanho do arquivo (KB, 0 = desativado)",
|
||||
"Line delta": "Diferença de linhas",
|
||||
@@ -882,6 +903,7 @@
|
||||
"No filter presets yet": "Ainda não há predefinições de filtro",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).",
|
||||
"No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.",
|
||||
"No matching commands": "Nenhum comando corresponde",
|
||||
"No matching photos": "Nenhuma foto correspondente",
|
||||
"No metadata found": "Nenhum metadado encontrado",
|
||||
"No model credits were built into this copy.": "Esta cópia não inclui créditos de modelos.",
|
||||
@@ -1006,6 +1028,7 @@
|
||||
"Presets (Shift+P)": "Predefinições (Shift+P)",
|
||||
"Presets and profiles": "Predefinições e perfis",
|
||||
"Presets ▾": "Predefinições",
|
||||
"Press keys…": "Pressione as teclas…",
|
||||
"Preview": "Pré-visualização",
|
||||
"Preview Build Progress": "Progresso da geração de pré-visualizações",
|
||||
"Preview only": "Somente pré-visualização",
|
||||
@@ -1050,6 +1073,16 @@
|
||||
"Radius": "Raio",
|
||||
"Random": "Aleatória",
|
||||
"Range": "Faixa",
|
||||
"Rate ★": "Avaliar ★",
|
||||
"Rate ★ and advance": "Avaliar ★ e avançar",
|
||||
"Rate ★★": "Avaliar ★★",
|
||||
"Rate ★★ and advance": "Avaliar ★★ e avançar",
|
||||
"Rate ★★★": "Avaliar ★★★",
|
||||
"Rate ★★★ and advance": "Avaliar ★★★ e avançar",
|
||||
"Rate ★★★★": "Avaliar ★★★★",
|
||||
"Rate ★★★★ and advance": "Avaliar ★★★★ e avançar",
|
||||
"Rate ★★★★★": "Avaliar ★★★★★",
|
||||
"Rate ★★★★★ and advance": "Avaliar ★★★★★ e avançar",
|
||||
"Rating": "Avaliação",
|
||||
"Rating cleared": "Avaliação removida",
|
||||
"Ratings & flags": "Avaliações e marcações",
|
||||
@@ -1063,6 +1096,7 @@
|
||||
"Read mode": "Modo de leitura",
|
||||
"Read the SAM License": "Ler a licença do SAM",
|
||||
"Reading": "Lendo",
|
||||
"Reading Lightroom catalog…": "Lendo catálogo do Lightroom…",
|
||||
"Reading folder…": "Lendo pasta…",
|
||||
"Real name": "Nome real",
|
||||
"Recent": "Recentes",
|
||||
@@ -1076,6 +1110,7 @@
|
||||
"Red Primary": "Primária vermelha",
|
||||
"Red Saturation": "Saturação do vermelho",
|
||||
"Red channel": "Canal vermelho",
|
||||
"Red label": "Rótulo vermelho",
|
||||
"Red/Cyan Fringe": "Franja vermelha/ciano",
|
||||
"Redo": "Refazer",
|
||||
"Reference": "Referência",
|
||||
@@ -1116,6 +1151,7 @@
|
||||
"Remove from Target Album": "Remover do álbum de destino",
|
||||
"Remove from survey": "Remover da inspeção",
|
||||
"Remove location info": "Remover informações de local",
|
||||
"Remove shortcut": "Remover atalho",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Remover esta caixa de rosto (desfaça com Editar ▸ Desfazer)",
|
||||
"Remove this filter": "Remover este filtro",
|
||||
"Remove this group": "Remover este grupo",
|
||||
@@ -1139,7 +1175,9 @@
|
||||
"Rendering…": "Renderizando…",
|
||||
"Reorder Masks": "Reordenar máscaras",
|
||||
"Reset": "Redefinir",
|
||||
"Reset All": "Redefinir tudo",
|
||||
"Reset All Curves": "Redefinir todas as curvas",
|
||||
"Reset All Keyboard Shortcuts": "Redefinir todos os atalhos de teclado",
|
||||
"Reset Before to Import State": "Redefinir Antes para o estado de importação",
|
||||
"Reset Control": "Redefinir controle",
|
||||
"Reset Crop": "Redefinir recorte",
|
||||
@@ -1154,6 +1192,7 @@
|
||||
"Resize to": "Redimensionar para",
|
||||
"Resolution (ppi)": "Resolução (ppi)",
|
||||
"Restore": "Restaurar",
|
||||
"Restore (no shortcut)": "Restaurar (sem atalho)",
|
||||
"Restore Library from Backup…": "Restaurar biblioteca do backup…",
|
||||
"Restore Version": "Restaurar versão",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Restaurar mantém as edições; Importar como novas apaga antes o registro antigo.",
|
||||
@@ -1200,6 +1239,7 @@
|
||||
"Save as set": "Salvar como conjunto",
|
||||
"Save as…": "Salvar como…",
|
||||
"Save preset": "Salvar predefinição",
|
||||
"Saving Lightroom import index…": "Salvando índice de importação do Lightroom…",
|
||||
"Scale": "Escala",
|
||||
"Scan failed": "Falha na verificação",
|
||||
"Scanning": "Examinando",
|
||||
@@ -1207,6 +1247,7 @@
|
||||
"Screen": "Tela",
|
||||
"Search": "Pesquisar",
|
||||
"Search Photos": "Pesquisar fotos",
|
||||
"Search commands or keys": "Pesquisar comandos ou teclas",
|
||||
"Search…": "Pesquisar…",
|
||||
"Seasons": "Estações",
|
||||
"Second Window": "Segunda janela",
|
||||
@@ -1237,6 +1278,7 @@
|
||||
"Set Develop Values": "Definir valores de revelação",
|
||||
"Set Flag": "Definir marcação",
|
||||
"Set Image Zoom and Pan": "Definir zoom e posição da imagem",
|
||||
"Set Keyboard Shortcut": "Definir atalho de teclado",
|
||||
"Set Mask Adjustments": "Definir ajustes da máscara",
|
||||
"Set Point Curve": "Definir curva de pontos",
|
||||
"Set Profile": "Definir perfil",
|
||||
@@ -1286,6 +1328,7 @@
|
||||
"Show Pins": "Mostrar pinos",
|
||||
"Show Source": "Mostrar origem",
|
||||
"Show Thumbnails": "Mostrar miniaturas",
|
||||
"Show commands without a shortcut": "Mostrar comandos sem atalho",
|
||||
"Show destination gamut warning": "Mostrar aviso de gamut de destino",
|
||||
"Show display gamut warning": "Mostrar aviso de gamut da tela",
|
||||
"Show file names": "Mostrar nomes dos arquivos",
|
||||
@@ -1390,6 +1433,7 @@
|
||||
"Text direction": "Direção do texto",
|
||||
"Texture": "Textura",
|
||||
"That includes the photos in the folders inside it.": "Isso inclui as fotos das pastas dentro dela.",
|
||||
"That key can't be used as a shortcut": "Essa tecla não pode ser usada como atalho",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "O modelo SAM 3 está instalado: as máscaras Objeto e Descrever estão prontas.",
|
||||
"The download didn't work:": "O download não funcionou:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "O número no fim do nome original (IMG_0042 → 0042)",
|
||||
@@ -1401,7 +1445,7 @@
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Elas vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
|
||||
"Thirds": "Terços",
|
||||
"This Computer": "Este computador",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Esta versão ainda não tem um local de download para o modelo: coloque os arquivos você mesmo na pasta acima (veja docs/ai-masks.md).",
|
||||
"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.": "Esta versão ainda não consegue baixar o modelo. Para instalá-lo manualmente, coloque model.safetensors, vocab.json e merges.txt do facebook/sam3 da Meta na pasta acima: Objeto e Descrever funcionam assim que eles estiverem lá.",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Esta pasta não está disponível (unidade desconectada?). As pré-visualizações inteligentes não são geradas nem usadas até ela voltar ou você escolher outra pasta.",
|
||||
"This photo can't be opened": "Não foi possível abrir esta foto",
|
||||
"Threshold": "Limite",
|
||||
@@ -1429,6 +1473,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "Ative Incluir subpastas ou escolha outra pasta em Local",
|
||||
"Type": "Tipo",
|
||||
"Uncheck All": "Desmarcar tudo",
|
||||
"Uncheck the files of this type": "Desmarcar os arquivos deste tipo",
|
||||
"Undo": "Desfazer",
|
||||
"Unedited": "Não editada",
|
||||
"Unedited raws": "RAWs não editados",
|
||||
@@ -1505,6 +1550,7 @@
|
||||
"Yellow Hue": "Matiz do amarelo",
|
||||
"Yellow Luminance": "Luminância do amarelo",
|
||||
"Yellow Saturation": "Saturação do amarelo",
|
||||
"Yellow label": "Rótulo amarelo",
|
||||
"Yes": "Sim",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Você pode fechar isto: o download continua e retoma se for interrompido.",
|
||||
"Your Name": "Seu nome",
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft пока не умеет декодировать этот вариант RAW ({}). Вы редактируете встроенный в файл JPEG-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
|
||||
"LightCraft could not open its window: {e}": "LightCraft не удалось открыть окно: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не удалось открыть окно: {e}\n\nПодробности в файле журнала: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom проверен: фото — {photos}, коллекций — {collections}, отсутствует — {missing}",
|
||||
"Lightroom import complete: {imported} photos added": "Импорт из Lightroom завершён: добавлено фото — {imported}",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Импорт из Lightroom завершён: добавлено фото — {imported}, ошибок — {failed}",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Импорт из Lightroom завершён: добавлено фото — {imported}; предупреждение индекса: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Ошибка импорта из Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Ошибка проверки Lightroom: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
|
||||
"Matches: {}": "Совпадения: {}",
|
||||
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
|
||||
@@ -91,11 +97,13 @@
|
||||
"Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}",
|
||||
"Quick Develop: {name}": "Быстрая коррекция: {name}",
|
||||
"Rated {}": "Оценка: {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Чтение каталога Lightroom… {done} из {total}",
|
||||
"Reading photos… {done} of {total}": "Чтение фото… {done} из {total}",
|
||||
"Reading photos… {} of {}": "Чтение фото… {} из {}",
|
||||
"Redo {}": "Повторить {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "Удалить «{name}» и фото ({count}){:.0} из библиотеки?",
|
||||
"Remove “{}” from Favorites": "Убрать «{}» из избранного",
|
||||
"Removed from {}": "Удалено из: {}",
|
||||
"Removed {what} from {name}": "{what}: удалено из {name}",
|
||||
"Rename {n} Photos…": "Переименовать фото: {n}…",
|
||||
"Rename “{name}”": "Переименовать «{name}»",
|
||||
@@ -104,6 +112,7 @@
|
||||
"Replace {} with:": "Заменить {} на:",
|
||||
"Reset {name}": "Сбросить: {name}",
|
||||
"Reset {} Channel": "Сбросить канал: {}",
|
||||
"Restore {}": "Восстановить {}",
|
||||
"Restore {} Photo{}": "Восстановить фото: {}{:.0}",
|
||||
"Saved export preset “{}”": "Пресет экспорта «{}» сохранён",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Add/Subtract/Intersect Mask": "Добавить/вычесть/пересечь маску",
|
||||
"Added to photos you import that don't already have it.": "Добавляется к импортируемым фото, у которых этого ещё нет.",
|
||||
"Adding": "Добавление",
|
||||
"Adding Lightroom photos…": "Добавление фото из Lightroom…",
|
||||
"Advanced search…": "Расширенный поиск…",
|
||||
"After": "После",
|
||||
"After 30 days": "Через 30 дней",
|
||||
@@ -124,6 +125,7 @@
|
||||
"Blue Primary": "Основной синий",
|
||||
"Blue Saturation": "Насыщенность синего",
|
||||
"Blue channel": "Синий канал",
|
||||
"Blue label": "Синяя метка",
|
||||
"Blue/Yellow Fringe": "Сине-жёлтая кайма",
|
||||
"Blurry photos below the score are flagged as rejects.": "Размытые фото с оценкой ниже порога помечаются как отклонённые.",
|
||||
"Boundary Warp": "Деформация границ",
|
||||
@@ -155,6 +157,7 @@
|
||||
"Catalog Statistics": "Статистика каталога",
|
||||
"Check All": "Отметить все",
|
||||
"Check Output Path": "Проверить путь вывода",
|
||||
"Check the files of this type": "Отметить файлы этого типа",
|
||||
"Choose Another Library…": "Выбрать другую библиотеку…",
|
||||
"Choose Edit Settings to Copy": "Выбрать настройки для копирования",
|
||||
"Choose Edit Settings to Copy…": "Выбрать настройки для копирования…",
|
||||
@@ -175,8 +178,11 @@
|
||||
"Clear Reference": "Убрать эталон",
|
||||
"Clear all": "Очистить всё",
|
||||
"Clear form": "Очистить форму",
|
||||
"Clear rating": "Снять оценку",
|
||||
"Clear rating and advance": "Снять оценку и перейти дальше",
|
||||
"Cleared History": "История очищена",
|
||||
"Click Zoom Ratio": "Масштаб по щелчку",
|
||||
"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": "Щёлкните по фото, чтобы выбрать цвет · ⇧+щелчок добавляет ещё",
|
||||
@@ -395,6 +401,7 @@
|
||||
"File Path": "Путь к файлу",
|
||||
"File Size": "Размер файла",
|
||||
"File name": "Имя файла",
|
||||
"File types:": "Типы файлов:",
|
||||
"Fill Edges": "Заполнить края",
|
||||
"Film": "Плёнка",
|
||||
"Filmstrip": "Лента",
|
||||
@@ -415,6 +422,7 @@
|
||||
"Fit on Screen": "Вписать в экран",
|
||||
"Fit to width": "По ширине",
|
||||
"Fit visible": "Вписать видимое",
|
||||
"Fixed keys": "Фиксированные клавиши",
|
||||
"Flag": "Флаг",
|
||||
"Flag as Pick": "Пометить как выбранное",
|
||||
"Flag as Reject": "Пометить как отклонённое",
|
||||
@@ -468,6 +476,7 @@
|
||||
"Green Primary": "Основной зелёный",
|
||||
"Green Saturation": "Насыщенность зелёного",
|
||||
"Green channel": "Зелёный канал",
|
||||
"Green label": "Зелёная метка",
|
||||
"Grid": "Сетка",
|
||||
"Grid Info": "Информация сетки",
|
||||
"Gritty Street": "Жёсткая улица",
|
||||
@@ -509,12 +518,15 @@
|
||||
"History & Activity (Y)": "История и активность (Y)",
|
||||
"Horizontal": "По горизонтали",
|
||||
"Horizontal text": "Горизонтальный текст",
|
||||
"How to install the model": "Как установить модель",
|
||||
"Hue": "Тон",
|
||||
"Hue Range": "Диапазон тонов",
|
||||
"If file exists": "Если файл существует",
|
||||
"Image": "Изображение",
|
||||
"Import": "Импорт",
|
||||
"Import Date": "Дата импорта",
|
||||
"Import Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
"Import Lightroom Catalog…": "Импорт каталога Lightroom…",
|
||||
"Import Photos": "Импорт фото",
|
||||
"Import Photos…": "Импорт фото…",
|
||||
"Import Point Curve Presets": "Импортировать пресеты точечной кривой",
|
||||
@@ -528,7 +540,10 @@
|
||||
"Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Импортируйте фото через Файл → Импорт фото… (Cmd+Shift+I) или перетащите их сюда",
|
||||
"Imported Photos": "Импортированные фото",
|
||||
"Importing": "Импорт",
|
||||
"Importing Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "При импорте источник сканируется на фото; папка не добавляется в «Локальное» (чтобы работать с папкой без импорта, используйте Локальное → Выбрать папку…).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "В сетке фото и квадратной сетке: пометить как выбранное и перейти дальше",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "В сетке фото и квадратной сетке: развернуть или свернуть стопку",
|
||||
"In Recently Deleted": "В «Недавно удалённых»",
|
||||
"In library": "В библиотеке",
|
||||
"In memory — nothing is saved": "В памяти — ничего не сохраняется",
|
||||
@@ -540,7 +555,10 @@
|
||||
"Info": "Информация",
|
||||
"Info (I)": "Информация (I)",
|
||||
"Info overlay": "Информационная накладка",
|
||||
"Inspect Lightroom Catalog": "Проверить каталог Lightroom",
|
||||
"Inspect Photo": "Просмотр фото",
|
||||
"Inspecting Lightroom Catalog": "Проверка каталога Lightroom",
|
||||
"Install the SAM 3 Model": "Установка модели SAM 3",
|
||||
"Instant ’70s": "Мгновенный стиль 70-х",
|
||||
"Interface": "Интерфейс",
|
||||
"Interface language": "Язык интерфейса",
|
||||
@@ -596,6 +614,9 @@
|
||||
"LightCraft on GitHub": "LightCraft на GitHub",
|
||||
"LightCraft website": "Сайт LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft — второе окно",
|
||||
"Lightroom Classic Catalog": "Каталог Lightroom Classic",
|
||||
"Lightroom import cancelled": "Импорт из Lightroom отменён",
|
||||
"Lightroom inspection cancelled": "Проверка Lightroom отменена",
|
||||
"Lights": "Светлые тона",
|
||||
"Limit file size (KB, 0 = off)": "Ограничить размер файла (КБ, 0 = выкл.)",
|
||||
"Linear Gradient": "Линейный градиент",
|
||||
@@ -724,6 +745,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 photos": "Нет подходящих фото",
|
||||
"No metadata found": "Метаданные не найдены",
|
||||
"No new presets": "Новых пресетов нет",
|
||||
@@ -826,6 +848,7 @@
|
||||
"Presets & Profiles": "Пресеты и профили",
|
||||
"Presets (Shift+P)": "Пресеты (Shift+P)",
|
||||
"Presets ▾": "Пресеты ⌄",
|
||||
"Press keys…": "Нажмите клавиши…",
|
||||
"Preview": "Просмотр",
|
||||
"Preview Build Progress": "Ход создания просмотров",
|
||||
"Preview only": "Только просмотр",
|
||||
@@ -864,6 +887,16 @@
|
||||
"Radius": "Радиус",
|
||||
"Random": "Случайно",
|
||||
"Range": "Диапазон",
|
||||
"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",
|
||||
@@ -875,6 +908,7 @@
|
||||
"Read mode": "Режим чтения",
|
||||
"Read the SAM License": "Прочитать лицензию SAM",
|
||||
"Reading": "Чтение",
|
||||
"Reading Lightroom catalog…": "Чтение каталога Lightroom…",
|
||||
"Reading folder…": "Чтение папки…",
|
||||
"Recently Added": "Недавно добавленные",
|
||||
"Recently Deleted": "Недавно удалённые",
|
||||
@@ -885,6 +919,7 @@
|
||||
"Red Primary": "Основной красный",
|
||||
"Red Saturation": "Насыщенность красного",
|
||||
"Red channel": "Красный канал",
|
||||
"Red label": "Красная метка",
|
||||
"Red/Cyan Fringe": "Красно-голубая кайма",
|
||||
"Redo": "Повторить",
|
||||
"Reference": "Эталон",
|
||||
@@ -910,6 +945,7 @@
|
||||
"Remove from Stack": "Убрать из стопки",
|
||||
"Remove from survey": "Убрать из обзора",
|
||||
"Remove location info": "Удалить сведения о местоположении",
|
||||
"Remove shortcut": "Удалить сочетание",
|
||||
"Remove this filter": "Убрать этот фильтр",
|
||||
"Remove this group": "Убрать эту группу",
|
||||
"Remove this rule": "Убрать это условие",
|
||||
@@ -930,7 +966,9 @@
|
||||
"Rendering": "Рендеринг",
|
||||
"Rendering…": "Рендеринг…",
|
||||
"Reset": "Сбросить",
|
||||
"Reset All": "Сбросить всё",
|
||||
"Reset All Curves": "Сбросить все кривые",
|
||||
"Reset All Keyboard Shortcuts": "Сбросить все сочетания клавиш",
|
||||
"Reset Before to Import State": "Сбросить «до» к состоянию при импорте",
|
||||
"Reset Control": "Сбросить элемент управления",
|
||||
"Reset Crop": "Сбросить кадрирование",
|
||||
@@ -944,6 +982,7 @@
|
||||
"Resize to": "Изменить размер до",
|
||||
"Resolution (ppi)": "Разрешение (ppi)",
|
||||
"Restore": "Восстановить",
|
||||
"Restore (no shortcut)": "Восстановить (без сочетания)",
|
||||
"Restore Library from Backup…": "Восстановить библиотеку из резервной копии…",
|
||||
"Restore Version": "Восстановить версию",
|
||||
"Retouch": "Ретушь",
|
||||
@@ -983,11 +1022,13 @@
|
||||
"Save as set": "Сохранить как набор",
|
||||
"Save as…": "Сохранить как…",
|
||||
"Save preset": "Сохранить пресет",
|
||||
"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.",
|
||||
"Screen": "Экран",
|
||||
"Search Photos": "Поиск фото",
|
||||
"Search commands or keys": "Поиск команд или клавиш",
|
||||
"Search…": "Поиск…",
|
||||
"Seasons": "Времена года",
|
||||
"Second Window": "Второе окно",
|
||||
@@ -1012,6 +1053,7 @@
|
||||
"Set Crop": "Задать кадрирование",
|
||||
"Set Develop Values": "Задать значения коррекции",
|
||||
"Set Flag": "Задать флаг",
|
||||
"Set Keyboard Shortcut": "Задать сочетание клавиш",
|
||||
"Set Mask Adjustments": "Задать настройки маски",
|
||||
"Set Point Curve": "Задать точечную кривую",
|
||||
"Set Profile": "Задать профиль",
|
||||
@@ -1053,6 +1095,7 @@
|
||||
"Show Pins": "Показать булавки",
|
||||
"Show Source": "Показать источник",
|
||||
"Show Thumbnails": "Показывать миниатюры",
|
||||
"Show commands without a shortcut": "Показывать команды без сочетаний",
|
||||
"Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода",
|
||||
"Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея",
|
||||
"Show file names": "Показывать имена файлов",
|
||||
@@ -1144,6 +1187,7 @@
|
||||
"Text": "Текст",
|
||||
"Text direction": "Направление текста",
|
||||
"Texture": "Текстура",
|
||||
"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-файл с его правками.",
|
||||
@@ -1151,6 +1195,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 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": "Порог",
|
||||
@@ -1174,6 +1219,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "Включите «Включая вложенные папки» или выберите другую папку в «Локальном»",
|
||||
"Type": "Тип",
|
||||
"Uncheck All": "Снять все отметки",
|
||||
"Uncheck the files of this type": "Снять отметку с файлов этого типа",
|
||||
"Undo": "Отменить",
|
||||
"Unedited": "Без правок",
|
||||
"Unflag": "Снять флаг",
|
||||
@@ -1243,6 +1289,7 @@
|
||||
"Yellow Hue": "Тон жёлтого",
|
||||
"Yellow Luminance": "Яркость жёлтого",
|
||||
"Yellow Saturation": "Насыщенность жёлтого",
|
||||
"Yellow label": "Жёлтая метка",
|
||||
"Yes": "Да",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Это окно можно закрыть: загрузка продолжится и возобновится после обрыва.",
|
||||
"Your library couldn't be opened": "Не удалось открыть вашу библиотеку",
|
||||
|
||||
@@ -50,13 +50,16 @@
|
||||
"Proof Preview · {}": "校样预览 · {0}",
|
||||
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
|
||||
"Rated {}": "已评级 {0}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "正在读取 Lightroom 目录… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
|
||||
"Redo {}": "重做:{0}",
|
||||
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
|
||||
"Removed from {}": "已从 {} 移除",
|
||||
"Rename {n} Photos…": "重命名 {n} 张照片…",
|
||||
"Rename “{name}”": "重命名“{name}”",
|
||||
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
|
||||
"Reset {} Channel": "重置 {0} 通道",
|
||||
"Restore {}": "恢复为 {}",
|
||||
"Restore {} Photo{}": "恢复 {0} 张照片{1:.0}",
|
||||
"Saved export preset “{}”": "已保存导出预设“{0}”",
|
||||
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
|
||||
@@ -114,6 +117,12 @@
|
||||
"Build Smart Previews ({scope})": "生成智能预览({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
|
||||
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已检查 Lightroom 目录:{photos} 张照片,{collections} 个收藏夹,{missing} 个缺失",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom 导入完成:已添加 {imported} 张照片",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 导入完成:已添加 {imported} 张照片,{failed} 张失败",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 导入完成:已添加 {imported} 张照片;索引警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom 导入失败:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom 检查失败:{e}{warning}",
|
||||
"Label Set: {name}": "标签集:{name}",
|
||||
"Quick Develop: {name}": "快速编辑:{name}",
|
||||
"Reset {name}": "重置{name}",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"Add/Subtract/Intersect Mask": "添加/减去/相交蒙版",
|
||||
"Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。",
|
||||
"Adding": "正在添加",
|
||||
"Adding Lightroom photos…": "正在添加 Lightroom 照片…",
|
||||
"Advanced search…": "高级搜索…",
|
||||
"After": "修改后",
|
||||
"After 30 days": "30 天后",
|
||||
@@ -152,6 +153,7 @@
|
||||
"Blue Primary": "蓝色原色",
|
||||
"Blue Saturation": "蓝色饱和度",
|
||||
"Blue channel": "蓝色通道",
|
||||
"Blue label": "蓝色标签",
|
||||
"Blue/Yellow Fringe": "蓝/黄边缘",
|
||||
"Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。",
|
||||
"Boundary Warp": "边界变形",
|
||||
@@ -194,6 +196,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "文件名中不允许的字符(/ \\ : * ? \" < > |)会替换为 -;与现有名称重复时添加 -1、-2…。",
|
||||
"Check All": "全部选中",
|
||||
"Check Output Path": "检查输出路径",
|
||||
"Check the files of this type": "勾选此类型的所有文件",
|
||||
"Checking names…": "正在检查名称…",
|
||||
"Choose Another Library…": "选择其他图库…",
|
||||
"Choose Edit Settings to Copy": "选择要复制的编辑设置",
|
||||
@@ -215,8 +218,11 @@
|
||||
"Clear Reference": "清除参考照片",
|
||||
"Clear all": "全部清除",
|
||||
"Clear form": "清除表单",
|
||||
"Clear rating": "清除星级",
|
||||
"Clear rating and advance": "清除星级并前进",
|
||||
"Cleared History": "已清除历史记录",
|
||||
"Click Zoom Ratio": "单击缩放比例",
|
||||
"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.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "单击对象将其选中 · 按住 ⌥ 单击可排除某一部分",
|
||||
@@ -481,11 +487,13 @@
|
||||
"File Path": "文件路径",
|
||||
"File Size": "文件大小",
|
||||
"File Type": "文件类型",
|
||||
"File types:": "文件类型:",
|
||||
"Filename": "文件名",
|
||||
"Fill": "填满",
|
||||
"First": "最早",
|
||||
"First commit": "首次提交",
|
||||
"Five stars": "五星",
|
||||
"Fixed keys": "固定按键",
|
||||
"Flagged as Pick": "已标记为留用",
|
||||
"Flagged as Reject": "已标记为排除",
|
||||
"Focal Length": "焦距",
|
||||
@@ -495,6 +503,7 @@
|
||||
"GPU rendering": "GPU 渲染",
|
||||
"Generated demo photo": "生成的演示照片",
|
||||
"Grab bag": "名单",
|
||||
"Green label": "绿色标签",
|
||||
"Group name": "分组名称",
|
||||
"HDR Merge Preview": "HDR 合并预览",
|
||||
"HDR Panorama Merge Preview": "HDR 全景合并预览",
|
||||
@@ -507,12 +516,19 @@
|
||||
"Hours": "小时",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import Lightroom Catalog": "导入 Lightroom 目录",
|
||||
"Import Lightroom Catalog…": "导入 Lightroom 目录…",
|
||||
"Import as new": "作为新照片导入",
|
||||
"Imported": "已导入",
|
||||
"Importing Lightroom Catalog": "正在导入 Lightroom 目录",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片网格和正方形网格中:标记为留用并前进",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片网格和正方形网格中:展开或折叠堆叠",
|
||||
"Info Overlay Off": "关闭信息叠加",
|
||||
"Info Overlay: Exposure": "信息叠加:曝光",
|
||||
"Info Overlay: File": "信息叠加:文件",
|
||||
"Insert at the cursor": "在光标处插入",
|
||||
"Inspect Lightroom Catalog": "检查 Lightroom 目录",
|
||||
"Inspecting Lightroom Catalog": "正在检查 Lightroom 目录",
|
||||
"Invert Component": "反相组件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
|
||||
"Label": "标签",
|
||||
@@ -524,6 +540,9 @@
|
||||
"Library saved": "图库已保存",
|
||||
"Licence:": "许可证:",
|
||||
"LightCraft could not start": "LightCraft 无法启动",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic 目录",
|
||||
"Lightroom import cancelled": "已取消 Lightroom 导入",
|
||||
"Lightroom inspection cancelled": "已取消 Lightroom 检查",
|
||||
"Line delta": "行数差",
|
||||
"Linear": "线性",
|
||||
"Lines +/−": "行数 +/−",
|
||||
@@ -550,6 +569,7 @@
|
||||
"Nested group": "嵌套组",
|
||||
"New time": "新时间",
|
||||
"No contributor data was built into this copy.": "此版本未内置贡献者数据。",
|
||||
"No matching commands": "没有匹配的命令",
|
||||
"No model credits were built into this copy.": "此版本未内置模型致谢信息。",
|
||||
"No named people in these photos": "这些照片中没有已命名的人物",
|
||||
"No named people yet": "还没有已命名的人物",
|
||||
@@ -572,6 +592,7 @@
|
||||
"Pictures": "图片",
|
||||
"Prepare AI Masks": "准备 AI 蒙版",
|
||||
"Presets and profiles": "预设和配置文件",
|
||||
"Press keys…": "请按键…",
|
||||
"Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG",
|
||||
"Proof copy created": "已创建校样副本",
|
||||
"Quit Anyway": "仍然退出",
|
||||
@@ -579,12 +600,24 @@
|
||||
"RAW decoding": "RAW 解码",
|
||||
"Radial": "径向",
|
||||
"Random": "随机",
|
||||
"Rate ★": "评为 ★",
|
||||
"Rate ★ and advance": "评为 ★ 并前进",
|
||||
"Rate ★★": "评为 ★★",
|
||||
"Rate ★★ and advance": "评为 ★★ 并前进",
|
||||
"Rate ★★★": "评为 ★★★",
|
||||
"Rate ★★★ and advance": "评为 ★★★ 并前进",
|
||||
"Rate ★★★★": "评为 ★★★★",
|
||||
"Rate ★★★★ and advance": "评为 ★★★★ 并前进",
|
||||
"Rate ★★★★★": "评为 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "评为 ★★★★★ 并前进",
|
||||
"Rating cleared": "已清除星级",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "阅读 SAM 许可证",
|
||||
"Reading Lightroom catalog…": "正在读取 Lightroom 目录…",
|
||||
"Real name": "真实姓名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用的关键字",
|
||||
"Red label": "红色标签",
|
||||
"Refine": "细化",
|
||||
"Refine AI Mask Detail": "细化 AI 蒙版细节",
|
||||
"Refine Edges": "细化边缘",
|
||||
@@ -596,9 +629,12 @@
|
||||
"Remove Face Box": "移除人脸框",
|
||||
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
|
||||
"Remove from Target Album": "从目标相册中移除",
|
||||
"Remove shortcut": "移除快捷键",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
|
||||
"Rename Component": "重命名组件",
|
||||
"Reorder Masks": "重新排序蒙版",
|
||||
"Reset All": "全部重置",
|
||||
"Reset All Keyboard Shortcuts": "重置所有键盘快捷键",
|
||||
"Reshuffle": "重新随机排序",
|
||||
"File name": "文件名",
|
||||
"Fill Edges": "填充边缘",
|
||||
@@ -1144,6 +1180,7 @@
|
||||
"Resize to": "调整大小为",
|
||||
"Resolution (ppi)": "分辨率 (ppi)",
|
||||
"Restore": "恢复",
|
||||
"Restore (no shortcut)": "恢复(无快捷键)",
|
||||
"Restore Library from Backup…": "从备份恢复图库…",
|
||||
"Restore Version": "恢复版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
|
||||
@@ -1190,6 +1227,7 @@
|
||||
"Save as set": "保存为集",
|
||||
"Save as…": "另存为…",
|
||||
"Save preset": "保存预设",
|
||||
"Saving Lightroom import index…": "正在保存 Lightroom 导入索引…",
|
||||
"Scale": "缩放",
|
||||
"Scan failed": "扫描失败",
|
||||
"Scanning": "正在扫描",
|
||||
@@ -1197,6 +1235,7 @@
|
||||
"Screen": "屏幕",
|
||||
"Search": "搜索",
|
||||
"Search Photos": "搜索照片",
|
||||
"Search commands or keys": "搜索命令或按键",
|
||||
"Search…": "搜索…",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第二个窗口",
|
||||
@@ -1227,6 +1266,7 @@
|
||||
"Set Develop Values": "设置编辑参数",
|
||||
"Set Flag": "设置旗标",
|
||||
"Set Image Zoom and Pan": "设置图像缩放和平移",
|
||||
"Set Keyboard Shortcut": "设置键盘快捷键",
|
||||
"Set Mask Adjustments": "设置蒙版调整",
|
||||
"Set Point Curve": "设置点曲线",
|
||||
"Set Profile": "设置配置文件",
|
||||
@@ -1276,6 +1316,7 @@
|
||||
"Show Pins": "显示图钉",
|
||||
"Show Source": "显示来源",
|
||||
"Show Thumbnails": "显示缩略图",
|
||||
"Show commands without a shortcut": "显示没有快捷键的命令",
|
||||
"Show destination gamut warning": "显示目标色域警告",
|
||||
"Show display gamut warning": "显示显示器色域警告",
|
||||
"Show file names": "显示文件名",
|
||||
@@ -1379,6 +1420,7 @@
|
||||
"Text": "文字",
|
||||
"Text direction": "文字方向",
|
||||
"Texture": "纹理",
|
||||
"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 number at the end of the original name (IMG_0042 → 0042)": "原始名称末尾的数字(IMG_0042 → 0042)",
|
||||
@@ -1388,7 +1430,6 @@
|
||||
"Theme": "主题",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
|
||||
"Thirds": "三分法",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下载地址:请自行将文件放入上方的文件夹(参见 docs/ai-masks.md)。",
|
||||
"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": "阈值",
|
||||
@@ -1416,6 +1457,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹",
|
||||
"Type": "类型",
|
||||
"Uncheck All": "全部取消选中",
|
||||
"Uncheck the files of this type": "取消勾选此类型的所有文件",
|
||||
"Undo": "撤销",
|
||||
"Unedited": "未编辑",
|
||||
"Unedited raws": "未编辑的 RAW",
|
||||
@@ -1492,6 +1534,7 @@
|
||||
"Yellow Hue": "黄色色相",
|
||||
"Yellow Luminance": "黄色明度",
|
||||
"Yellow Saturation": "黄色饱和度",
|
||||
"Yellow label": "黄色标签",
|
||||
"Yes": "是",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。",
|
||||
"Your Name": "你的名字",
|
||||
@@ -1593,5 +1636,8 @@
|
||||
"Expand": "展开",
|
||||
"Remove Disk from Library": "从图库中移除磁盘",
|
||||
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。",
|
||||
"How to install the model": "如何安装模型",
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。"
|
||||
}
|
||||
@@ -42,6 +42,12 @@
|
||||
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
|
||||
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已檢查 Lightroom 目錄:{photos} 張相片,{collections} 個收藏集,{missing} 個遺失",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom 匯入完成:已加入 {imported} 張相片",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 匯入完成:已加入 {imported} 張相片,{failed} 張失敗",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 匯入完成:已加入 {imported} 張相片;索引警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom 匯入失敗:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom 檢查失敗:{e}{warning}",
|
||||
"Label Set: {name}": "標籤集:{name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft 無法開啟視窗:{e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
|
||||
@@ -59,15 +65,18 @@
|
||||
"Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
|
||||
"Quick Develop: {name}": "快速顯像:{name}",
|
||||
"Rated {}": "評分:{}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "正在讀取 Lightroom 目錄… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
|
||||
"Reading photos… {} of {}": "正在讀取照片… {}/{}",
|
||||
"Redo {}": "重做 {}",
|
||||
"Remove “{}” from Favorites": "將「{}」從喜好項目移除",
|
||||
"Removed from {}": "已從 {} 移除",
|
||||
"Rename {n} Photos…": "重新命名 {n} 張照片…",
|
||||
"Rename “{name}”": "重新命名「{name}」",
|
||||
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
|
||||
"Reset {name}": "重設{name}",
|
||||
"Reset {} Channel": "重設 {} 通道",
|
||||
"Restore {}": "還原為 {}",
|
||||
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
|
||||
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
|
||||
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
|
||||
"Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
|
||||
"Adding": "正在新增",
|
||||
"Adding Lightroom photos…": "正在加入 Lightroom 相片…",
|
||||
"Advanced search…": "進階搜尋…",
|
||||
"After": "編輯後",
|
||||
"After 30 days": "30 天後",
|
||||
@@ -152,6 +153,7 @@
|
||||
"Blue Primary": "藍色原色",
|
||||
"Blue Saturation": "藍色飽和度",
|
||||
"Blue channel": "藍色通道",
|
||||
"Blue label": "藍色標籤",
|
||||
"Blue/Yellow Fringe": "藍/黃色邊緣",
|
||||
"Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。",
|
||||
"Boundary Warp": "邊界變形",
|
||||
@@ -194,6 +196,7 @@
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "檔名中不允許的字元(/ \\ : * ? \" < > |)會改為 -;與現有名稱重複時加上 -1、-2…。",
|
||||
"Check All": "全部勾選",
|
||||
"Check Output Path": "檢驗輸出路徑",
|
||||
"Check the files of this type": "勾選此類型的所有檔案",
|
||||
"Checking names…": "正在檢查名稱…",
|
||||
"Choose Another Library…": "選擇其他照片庫…",
|
||||
"Choose Edit Settings to Copy": "選擇要複製的編輯設定",
|
||||
@@ -215,8 +218,11 @@
|
||||
"Clear Reference": "清除參考照片",
|
||||
"Clear all": "全部清除",
|
||||
"Clear form": "清除表單",
|
||||
"Clear rating": "清除評分",
|
||||
"Clear rating and advance": "清除評分並前進",
|
||||
"Cleared History": "已清除歷史紀錄",
|
||||
"Click Zoom Ratio": "點擊縮放比例",
|
||||
"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.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "點擊物件即可選取 · 按住 ⌥ 點擊可排除某個部分",
|
||||
@@ -481,11 +487,13 @@
|
||||
"File Path": "檔案路徑",
|
||||
"File Size": "檔案大小",
|
||||
"File Type": "檔案類型",
|
||||
"File types:": "檔案類型:",
|
||||
"Filename": "檔名",
|
||||
"Fill": "填滿",
|
||||
"First": "最早",
|
||||
"First commit": "首次提交",
|
||||
"Five stars": "五顆星",
|
||||
"Fixed keys": "固定按鍵",
|
||||
"Flagged as Pick": "已標記為精選",
|
||||
"Flagged as Reject": "已標記為排除",
|
||||
"Focal Length": "焦距",
|
||||
@@ -495,6 +503,7 @@
|
||||
"GPU rendering": "GPU 算繪",
|
||||
"Generated demo photo": "產生的示範照片",
|
||||
"Grab bag": "名單",
|
||||
"Green label": "綠色標籤",
|
||||
"Group name": "群組名稱",
|
||||
"HDR Merge Preview": "HDR 合併預覽",
|
||||
"HDR Panorama Merge Preview": "HDR 全景合併預覽",
|
||||
@@ -507,12 +516,19 @@
|
||||
"Hours": "小時",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import Lightroom Catalog": "匯入 Lightroom 目錄",
|
||||
"Import Lightroom Catalog…": "匯入 Lightroom 目錄…",
|
||||
"Import as new": "以新照片匯入",
|
||||
"Imported": "已匯入",
|
||||
"Importing Lightroom Catalog": "正在匯入 Lightroom 目錄",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片格狀檢視與方格檢視中:標記為精選並前進",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片格狀檢視與方格檢視中:展開或收合堆疊",
|
||||
"Info Overlay Off": "關閉資訊疊加",
|
||||
"Info Overlay: Exposure": "資訊疊加:曝光",
|
||||
"Info Overlay: File": "資訊疊加:檔案",
|
||||
"Insert at the cursor": "在游標處插入",
|
||||
"Inspect Lightroom Catalog": "檢查 Lightroom 目錄",
|
||||
"Inspecting Lightroom Catalog": "正在檢查 Lightroom 目錄",
|
||||
"Invert Component": "反轉元件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
|
||||
"Label": "標籤",
|
||||
@@ -524,6 +540,9 @@
|
||||
"Library saved": "照片庫已儲存",
|
||||
"Licence:": "授權:",
|
||||
"LightCraft could not start": "LightCraft 無法啟動",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic 目錄",
|
||||
"Lightroom import cancelled": "已取消 Lightroom 匯入",
|
||||
"Lightroom inspection cancelled": "已取消 Lightroom 檢查",
|
||||
"Line delta": "行數差",
|
||||
"Linear": "線性",
|
||||
"Lines +/−": "行數 +/−",
|
||||
@@ -550,6 +569,7 @@
|
||||
"Nested group": "巢狀群組",
|
||||
"New time": "新時間",
|
||||
"No contributor data was built into this copy.": "此版本未內建貢獻者資料。",
|
||||
"No matching commands": "沒有符合的命令",
|
||||
"No model credits were built into this copy.": "此版本未內建模型致謝資訊。",
|
||||
"No named people in these photos": "這些照片中沒有已命名的人物",
|
||||
"No named people yet": "尚無已命名的人物",
|
||||
@@ -572,6 +592,7 @@
|
||||
"Pictures": "圖片",
|
||||
"Prepare AI Masks": "準備 AI 遮色片",
|
||||
"Presets and profiles": "預設集與描述檔",
|
||||
"Press keys…": "請按鍵…",
|
||||
"Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG",
|
||||
"Proof copy created": "已建立校樣副本",
|
||||
"Quit Anyway": "仍要結束",
|
||||
@@ -579,12 +600,24 @@
|
||||
"RAW decoding": "RAW 解碼",
|
||||
"Radial": "放射狀",
|
||||
"Random": "隨機",
|
||||
"Rate ★": "評為 ★",
|
||||
"Rate ★ and advance": "評為 ★ 並前進",
|
||||
"Rate ★★": "評為 ★★",
|
||||
"Rate ★★ and advance": "評為 ★★ 並前進",
|
||||
"Rate ★★★": "評為 ★★★",
|
||||
"Rate ★★★ and advance": "評為 ★★★ 並前進",
|
||||
"Rate ★★★★": "評為 ★★★★",
|
||||
"Rate ★★★★ and advance": "評為 ★★★★ 並前進",
|
||||
"Rate ★★★★★": "評為 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "評為 ★★★★★ 並前進",
|
||||
"Rating cleared": "已清除評分",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "閱讀 SAM 授權",
|
||||
"Reading Lightroom catalog…": "正在讀取 Lightroom 目錄…",
|
||||
"Real name": "真實姓名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用的關鍵字",
|
||||
"Red label": "紅色標籤",
|
||||
"Refine": "調整",
|
||||
"Refine AI Mask Detail": "調整 AI 遮色片細節",
|
||||
"Refine Edges": "調整邊緣",
|
||||
@@ -596,9 +629,12 @@
|
||||
"Remove Face Box": "移除臉部框",
|
||||
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
|
||||
"Remove from Target Album": "從目標相簿移除",
|
||||
"Remove shortcut": "移除快速鍵",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
|
||||
"Rename Component": "重新命名元件",
|
||||
"Reorder Masks": "重新排序遮色片",
|
||||
"Reset All": "全部重設",
|
||||
"Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵",
|
||||
"Reshuffle": "重新隨機排序",
|
||||
"File name": "檔案名稱",
|
||||
"Fill Edges": "填補邊緣",
|
||||
@@ -1144,6 +1180,7 @@
|
||||
"Resize to": "調整尺寸為",
|
||||
"Resolution (ppi)": "解析度(ppi)",
|
||||
"Restore": "還原",
|
||||
"Restore (no shortcut)": "還原(無快速鍵)",
|
||||
"Restore Library from Backup…": "從備份還原照片庫…",
|
||||
"Restore Version": "還原版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
|
||||
@@ -1190,6 +1227,7 @@
|
||||
"Save as set": "儲存為關鍵字集",
|
||||
"Save as…": "另存新檔…",
|
||||
"Save preset": "儲存預設集",
|
||||
"Saving Lightroom import index…": "正在儲存 Lightroom 匯入索引…",
|
||||
"Scale": "縮放",
|
||||
"Scan failed": "掃描失敗",
|
||||
"Scanning": "正在掃描",
|
||||
@@ -1197,6 +1235,7 @@
|
||||
"Screen": "螢幕",
|
||||
"Search": "搜尋",
|
||||
"Search Photos": "搜尋照片",
|
||||
"Search commands or keys": "搜尋命令或按鍵",
|
||||
"Search…": "搜尋…",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第二視窗",
|
||||
@@ -1227,6 +1266,7 @@
|
||||
"Set Develop Values": "設定顯像參數",
|
||||
"Set Flag": "設定旗標",
|
||||
"Set Image Zoom and Pan": "設定影像縮放與平移",
|
||||
"Set Keyboard Shortcut": "設定鍵盤快速鍵",
|
||||
"Set Mask Adjustments": "設定遮色片調整",
|
||||
"Set Point Curve": "設定點曲線",
|
||||
"Set Profile": "設定描述檔",
|
||||
@@ -1276,6 +1316,7 @@
|
||||
"Show Pins": "顯示控制點",
|
||||
"Show Source": "顯示來源",
|
||||
"Show Thumbnails": "顯示縮圖",
|
||||
"Show commands without a shortcut": "顯示沒有快速鍵的命令",
|
||||
"Show destination gamut warning": "顯示目標色域警告",
|
||||
"Show display gamut warning": "顯示螢幕色域警告",
|
||||
"Show file names": "顯示檔案名稱",
|
||||
@@ -1379,6 +1420,7 @@
|
||||
"Text": "文字",
|
||||
"Text direction": "文字方向",
|
||||
"Texture": "紋理",
|
||||
"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 number at the end of the original name (IMG_0042 → 0042)": "原始名稱結尾的數字(IMG_0042 → 0042)",
|
||||
@@ -1388,7 +1430,6 @@
|
||||
"Theme": "主題",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
|
||||
"Thirds": "三分法",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下載位置:請自行將檔案放入上方的資料夾(請參閱 docs/ai-masks.md)。",
|
||||
"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": "臨界值",
|
||||
@@ -1416,6 +1457,7 @@
|
||||
"Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾",
|
||||
"Type": "類型",
|
||||
"Uncheck All": "全部取消勾選",
|
||||
"Uncheck the files of this type": "取消勾選此類型的所有檔案",
|
||||
"Undo": "復原",
|
||||
"Unedited": "未編輯",
|
||||
"Unedited raws": "未編輯的 RAW",
|
||||
@@ -1492,6 +1534,7 @@
|
||||
"Yellow Hue": "黃色色相",
|
||||
"Yellow Luminance": "黃色明度",
|
||||
"Yellow Saturation": "黃色飽和度",
|
||||
"Yellow label": "黃色標籤",
|
||||
"Yes": "是",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。",
|
||||
"Your Name": "你的名字",
|
||||
@@ -1593,5 +1636,8 @@
|
||||
"Expand": "展開",
|
||||
"Remove Disk from Library": "從照片庫移除磁碟",
|
||||
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。",
|
||||
"How to install the model": "如何安裝模型",
|
||||
"Install the SAM 3 Model": "安裝 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
|
||||
}
|
||||
@@ -54,8 +54,21 @@ fn wrap(r: Result<Value, String>) -> Outcome {
|
||||
}
|
||||
|
||||
pub fn all_commands(app: &LightcraftApp) -> Value {
|
||||
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
let keymap = &app.ui.settings.keymap;
|
||||
let mut v: Vec<Value> = app
|
||||
.session
|
||||
.commands()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id)));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
for (id, label, sc, menu) in crate::menus::ui_commands() {
|
||||
let sc = crate::shortcuts::binding(keymap, id, *sc);
|
||||
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
|
||||
}
|
||||
Value::Array(v)
|
||||
|
||||
@@ -1635,8 +1635,9 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (src, dest) = (base.join("card"), base.join("out"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
for i in 0..10u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
|
||||
// more files than a batch holds (see `batch_size`)
|
||||
for i in 0..20u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
|
||||
}
|
||||
@@ -1651,7 +1652,7 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
assert_eq!(opts.candidates.len(), 10);
|
||||
assert_eq!(opts.candidates.len(), 20);
|
||||
for id in ["button:importCopy", "button:importDest"] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), t);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
@@ -1670,8 +1671,8 @@ mod tests {
|
||||
assert!(h.app.import.is_none(), "finished");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
|
||||
let want: Vec<String> = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ language_table! {
|
||||
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
|
||||
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
|
||||
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
|
||||
Es, "es", "Español", "Latn", include_str!("../locales/es.json");
|
||||
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
|
||||
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
|
||||
}
|
||||
@@ -449,6 +450,9 @@ mod tests {
|
||||
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] {
|
||||
assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}");
|
||||
}
|
||||
for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] {
|
||||
assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}");
|
||||
}
|
||||
assert_eq!(Locale::parse_tag("xx"), None);
|
||||
}
|
||||
|
||||
@@ -571,6 +575,7 @@ mod tests {
|
||||
("app.language.simplifiedChinese", Locale::ZhHans),
|
||||
("app.language.japanese", Locale::Ja),
|
||||
("app.language.portuguese", Locale::PtBr),
|
||||
("app.language.spanish", Locale::Es),
|
||||
("app.language.german", Locale::De),
|
||||
("app.language.russian", Locale::Ru),
|
||||
];
|
||||
|
||||
@@ -18,8 +18,10 @@ use crate::render::Slot;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates).
|
||||
pub(crate) const BATCH: usize = 8;
|
||||
/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the
|
||||
/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`].
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const BATCH: usize = 8;
|
||||
|
||||
/// The review dialog's first size (points). It can be resized; the photo grid takes the height.
|
||||
pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0];
|
||||
@@ -87,6 +89,22 @@ pub struct ImportDialog {
|
||||
pub last_clicked: Option<usize>,
|
||||
}
|
||||
|
||||
/// A candidate's file type, as the review groups them: its format, or its extension when the
|
||||
/// probe named none (`JPEG` and `JPG` are one type).
|
||||
fn file_type(c: &ImportCandidate) -> String {
|
||||
let f = if c.format.trim().is_empty() {
|
||||
std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else {
|
||||
c.format.clone()
|
||||
};
|
||||
match f.trim().to_uppercase().as_str() {
|
||||
"JPEG" => "JPG".into(),
|
||||
"TIFF" => "TIF".into(),
|
||||
"" => "?".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportDialog {
|
||||
pub fn new(candidates: Vec<ImportCandidate>) -> Self {
|
||||
let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect();
|
||||
@@ -95,6 +113,29 @@ impl ImportDialog {
|
||||
pub fn importable(&self, i: usize) -> bool {
|
||||
self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty()))
|
||||
}
|
||||
/// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each.
|
||||
pub fn file_types(&self) -> Vec<(String, usize)> {
|
||||
let mut types: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for c in &self.candidates {
|
||||
*types.entry(file_type(c)).or_default() += 1;
|
||||
}
|
||||
types.into_iter().collect()
|
||||
}
|
||||
/// Any file of type `ty` is checked.
|
||||
pub fn type_checked(&self, ty: &str) -> bool {
|
||||
self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty)
|
||||
}
|
||||
/// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not
|
||||
/// the JPEGs kept beside them (issue #344).
|
||||
pub fn check_type(&mut self, ty: &str, on: bool) {
|
||||
for i in 0..self.candidates.len() {
|
||||
let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty);
|
||||
let importable = self.importable(i);
|
||||
if same && let Some(c) = self.checked.get_mut(i) {
|
||||
*c = on && importable;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Candidate `i` is a file whose photo is in Recently Deleted.
|
||||
pub fn is_trashed(&self, i: usize) -> bool {
|
||||
self.trashed.get(i).copied().unwrap_or(false)
|
||||
@@ -273,7 +314,7 @@ impl ImportRun {
|
||||
let ctx = ctx.clone();
|
||||
let work = move || {
|
||||
let files = job.expand(&queue);
|
||||
for chunk in files.chunks(BATCH) {
|
||||
for chunk in files.chunks(lightcraft_engine::import::batch_size()) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
@@ -819,6 +860,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// with more than one file type: a toggle per type checks or unchecks all of its files
|
||||
let types = d.file_types();
|
||||
if types.len() > 1 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim));
|
||||
for (ty, count) in &types {
|
||||
let on = d.type_checked(ty);
|
||||
let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on)
|
||||
.on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" }));
|
||||
if r.clicked() {
|
||||
d.check_type(ty, !on);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// candidate grid
|
||||
let cell = 116.0;
|
||||
let avail = ui.available_width();
|
||||
@@ -1390,6 +1446,32 @@ mod tests {
|
||||
ImportDialog::new(candidates)
|
||||
}
|
||||
|
||||
/// A toggle per file type: the raws only, without the JPEGs beside them (issue #344).
|
||||
#[test]
|
||||
fn file_types_check_and_uncheck_together() {
|
||||
let mut candidates: Vec<ImportCandidate> =
|
||||
["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect();
|
||||
candidates[2].format = "ARW".into();
|
||||
candidates[3].format = "JPEG".into();
|
||||
candidates[2].duplicate = Some("content".into());
|
||||
let mut d = ImportDialog::new(candidates);
|
||||
assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]);
|
||||
assert!(d.type_checked("JPG"));
|
||||
d.check_type("JPG", false);
|
||||
assert_eq!(d.checked, [true, false, false, false, true]);
|
||||
assert!(!d.type_checked("JPG"));
|
||||
d.check_type("PNG", false);
|
||||
d.check_type("ARW", true);
|
||||
// the duplicate stays unchecked: it isn't importable
|
||||
assert_eq!(d.checked, [true, false, false, false, false]);
|
||||
d.check_type("JPG", true);
|
||||
assert_eq!(d.checked, [true, true, false, true, false]);
|
||||
assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]);
|
||||
// an unknown type changes nothing
|
||||
d.check_type("CR3", false);
|
||||
assert_eq!(d.checked, [true, true, false, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_toggles_one_candidate() {
|
||||
let mut d = dialog();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod headless;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod import;
|
||||
pub mod lightroom_import;
|
||||
pub mod links;
|
||||
pub mod menubar;
|
||||
pub mod menus;
|
||||
@@ -33,12 +34,16 @@ mod tests_filmstrip;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_keymap;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_masking_layout;
|
||||
#[cfg(test)]
|
||||
mod tests_offline;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
@@ -104,6 +109,8 @@ pub struct Services {
|
||||
pub reveal: Option<RevealFn>,
|
||||
/// Choose a folder (Settings → General → Open Library…; desktop only).
|
||||
pub pick_folder: Option<PickFolder>,
|
||||
/// Open a Lightroom Classic `.lrcat` catalog for read-only import.
|
||||
pub pick_lightroom_catalog: Option<PickFolder>,
|
||||
/// Open a web link in the browser (Help menu, About, Discord button).
|
||||
pub open_url: Option<OpenUrlFn>,
|
||||
/// Open a file in an external editor (Edit in External Editor; desktop only).
|
||||
@@ -146,6 +153,9 @@ pub struct LightcraftApp {
|
||||
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
|
||||
/// leaves them alone so nothing fires twice.
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
/// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut
|
||||
/// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled.
|
||||
pub recording_shortcut: Option<String>,
|
||||
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
|
||||
pub headless_host: bool,
|
||||
/// Warnings to show one at a time (damaged settings files…, issue #103).
|
||||
@@ -191,6 +201,10 @@ pub struct LightcraftApp {
|
||||
pub import: Option<import::ImportTask>,
|
||||
/// A folder scan in progress (feeds the import review).
|
||||
pub scan: Option<import::ScanTask>,
|
||||
/// A Lightroom catalog inspect/import in progress.
|
||||
pub lightroom: Option<lightroom_import::LightroomTask>,
|
||||
/// Last terminal Lightroom result, exposed by the command's status/wait response.
|
||||
pub lightroom_last: Option<Value>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// Background file-system work of other commands (Find Missing Photos, auto import…).
|
||||
@@ -225,6 +239,7 @@ impl LightcraftApp {
|
||||
integrated_titlebar: false,
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
recording_shortcut: None,
|
||||
headless_host: false,
|
||||
notices: vec![],
|
||||
quit_prompt: None,
|
||||
@@ -250,6 +265,8 @@ impl LightcraftApp {
|
||||
merge: merge::MergeState::default(),
|
||||
import: None,
|
||||
scan: None,
|
||||
lightroom: None,
|
||||
lightroom_last: None,
|
||||
export: None,
|
||||
tasks: Default::default(),
|
||||
last_export_result: None,
|
||||
@@ -649,6 +666,7 @@ impl LightcraftApp {
|
||||
merge::poll(self, ctx);
|
||||
import::poll_scan(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
lightroom_import::tick(self, ctx);
|
||||
tasks::poll(self, ctx);
|
||||
self.preview_build_status(ctx);
|
||||
self.save_status(ctx);
|
||||
@@ -670,7 +688,7 @@ impl LightcraftApp {
|
||||
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
|
||||
const LABEL: &str = "Auto Import";
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && !self.tasks.is_running(LABEL) {
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && self.lightroom.is_none() && !self.tasks.is_running(LABEL) {
|
||||
self.ui.auto_import_at = now;
|
||||
let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
|
||||
let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
|
||||
@@ -711,10 +729,12 @@ impl LightcraftApp {
|
||||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
}
|
||||
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
|
||||
if !photos.is_empty()
|
||||
&& let Err(e) = import::start_paths(self, photos)
|
||||
{
|
||||
self.toast(ctx, e);
|
||||
if !photos.is_empty() {
|
||||
if self.lightroom.is_some() {
|
||||
self.toast(ctx, "Finish Lightroom catalog import before adding photos");
|
||||
} else if let Err(e) = import::start_paths(self, photos) {
|
||||
self.toast(ctx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +871,7 @@ impl LightcraftApp {
|
||||
panels::library_problem::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
lightroom_import::progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
//! Background Lightroom catalog inspection and import.
|
||||
//!
|
||||
//! Reading a catalog, probing its originals and staging its archive can take minutes on a
|
||||
//! removable or network drive. This task keeps that work off the owner thread, commits prepared
|
||||
//! data only on the owner thread, then finishes the archive index on a worker.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use egui::Align2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Inspect,
|
||||
Import,
|
||||
}
|
||||
|
||||
enum Message {
|
||||
Inspected(Result<Value, String>),
|
||||
Prepared(Result<Box<lightcraft_engine::lightroom_job::PreparedLightroom>, String>),
|
||||
Finalized(Result<(), String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
Reading,
|
||||
Committing,
|
||||
Finalizing,
|
||||
}
|
||||
|
||||
/// A Lightroom task in flight. The receiver is polled from the egui owner thread.
|
||||
pub struct LightroomTask {
|
||||
kind: Kind,
|
||||
path: PathBuf,
|
||||
cancel: Arc<AtomicBool>,
|
||||
total: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicUsize>,
|
||||
rx: mpsc::Receiver<Message>,
|
||||
phase: Phase,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
impl LightroomTask {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn status(&self) -> Value {
|
||||
let kind = match self.kind {
|
||||
Kind::Inspect => "inspect",
|
||||
Kind::Import => "import",
|
||||
};
|
||||
let phase = match self.phase {
|
||||
Phase::Reading => "reading",
|
||||
Phase::Committing => "committing",
|
||||
Phase::Finalizing => "finalizing",
|
||||
};
|
||||
json!({
|
||||
"running": true,
|
||||
"kind": kind,
|
||||
"path": self.path,
|
||||
"phase": phase,
|
||||
"done": self.done.load(Ordering::Relaxed),
|
||||
"total": self.total.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightroomTask {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn unsupported_wasm() -> Result<Value, String> {
|
||||
Err("Lightroom catalog import is unavailable in browser builds; open the catalog in the native LightCraft app".into())
|
||||
}
|
||||
|
||||
/// Whether a Lightroom task currently owns the catalog transition.
|
||||
pub fn is_running(app: &LightcraftApp) -> bool {
|
||||
app.lightroom.is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn busy(app: &LightcraftApp) -> Result<(), String> {
|
||||
if app.lightroom.is_some() {
|
||||
return Err("a Lightroom catalog task is already running".into());
|
||||
}
|
||||
if app.import.is_some() || app.scan.is_some() {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_inspect(
|
||||
path: PathBuf,
|
||||
cancel: Arc<AtomicBool>,
|
||||
total: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicUsize>,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-inspect".into())
|
||||
.spawn(move || {
|
||||
let result = lightcraft_engine::guard::catch("Lightroom inspect", || {
|
||||
lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done)
|
||||
})
|
||||
.and_then(|r| r.map(inspect_report));
|
||||
let _ = tx.send(Message::Inspected(result));
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom inspect: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_prepare(
|
||||
mut job: lightcraft_engine::lightroom_job::LightroomJob,
|
||||
cancel: Arc<AtomicBool>,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-import".into())
|
||||
.spawn(move || {
|
||||
let result =
|
||||
lightcraft_engine::guard::catch("Lightroom import", || job.prepare(&cancel)).and_then(|r| r.map(Box::new).map_err(|e| e.to_string()));
|
||||
if let Err(mpsc::SendError(Message::Prepared(Ok(prepared)))) = tx.send(Message::Prepared(result)) {
|
||||
// The owner dropped the task (for example while closing): staged copies/moves
|
||||
// must be put back even though no UI receiver remains to accept the result.
|
||||
prepared.rollback();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom import: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_finalize(
|
||||
finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-finalize".into())
|
||||
.spawn(move || {
|
||||
let result = lightcraft_engine::guard::catch("Lightroom archive finalization", || finalization.finish())
|
||||
.and_then(|r| r.map_err(|e| e.to_string()));
|
||||
let _ = tx.send(Message::Finalized(result));
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom archive finalization: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn spawn_finalize(
|
||||
_finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization,
|
||||
_tx: mpsc::Sender<Message>,
|
||||
_ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
unsupported_wasm().map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> Value {
|
||||
json!({
|
||||
"photos": data.photos.len(),
|
||||
"collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(),
|
||||
"missing": data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::<Vec<_>>(),
|
||||
"warnings": data.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) {
|
||||
let message = if let Some(e) = value.get("error").and_then(Value::as_str) {
|
||||
let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}"));
|
||||
if matches!(kind, Kind::Inspect) {
|
||||
crate::i18n::tr_format!("Lightroom inspection failed: {e}{warning}", e = e, warning = warning)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Lightroom import failed: {e}{warning}", e = e, warning = warning)
|
||||
}
|
||||
} else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) {
|
||||
crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection cancelled" } else { "Lightroom import cancelled" }).into()
|
||||
} else if matches!(kind, Kind::Inspect) {
|
||||
let photos = value["photos"].as_u64().unwrap_or(0);
|
||||
let collections = value["collections"].as_u64().unwrap_or(0);
|
||||
let missing = value["missing"].as_array().map_or(0, Vec::len);
|
||||
crate::i18n::tr_format!(
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing",
|
||||
photos = photos,
|
||||
collections = collections,
|
||||
missing = missing
|
||||
)
|
||||
} else {
|
||||
let report = value.get("report").unwrap_or(&value);
|
||||
let imported = report["imported"].as_u64().unwrap_or(0);
|
||||
let failed = report["failed"].as_array().map_or(0, Vec::len);
|
||||
let warning = value.get("indexWarning").and_then(Value::as_str);
|
||||
if let Some(warning) = warning {
|
||||
crate::i18n::tr_format!(
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}",
|
||||
imported = imported,
|
||||
warning = warning
|
||||
)
|
||||
} else if failed == 0 {
|
||||
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added", imported = imported)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed)
|
||||
}
|
||||
};
|
||||
app.lightroom_last = Some(value);
|
||||
app.lightroom = None;
|
||||
app.toast_for(ctx, message, 6.0);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result<Value, String> {
|
||||
busy(app)?;
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?;
|
||||
app.lightroom_last = None;
|
||||
app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
|
||||
Ok(json!({"running": true, "kind": "inspect", "path": path}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, ctx: &egui::Context) -> Result<Value, String> {
|
||||
busy(app)?;
|
||||
let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?;
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let total = job.total_atomic();
|
||||
let done = job.done_atomic();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
spawn_prepare(job, cancel.clone(), tx, ctx.clone())?;
|
||||
app.lightroom_last = None;
|
||||
app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
|
||||
Ok(json!({"running": true, "kind": "import", "path": path}))
|
||||
}
|
||||
|
||||
/// Run Lightroom UI commands before the generic engine dispatcher.
|
||||
pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context) -> Result<Value, String> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (app, id, p, ctx);
|
||||
return unsupported_wasm();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = p.get("path").and_then(Value::as_str).map(PathBuf::from);
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
|
||||
let status = p.get("status").and_then(Value::as_bool).unwrap_or(false) || path.is_none();
|
||||
if status {
|
||||
if p.get("cancel").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(task) = app.lightroom.as_mut()
|
||||
{
|
||||
task.cancel();
|
||||
}
|
||||
if wait && app.lightroom.is_some() {
|
||||
return wait_for(app, ctx, std::time::Duration::from_secs(600));
|
||||
}
|
||||
return Ok(app.lightroom.as_ref().map(LightroomTask::status).or_else(|| app.lightroom_last.clone()).unwrap_or(Value::Null));
|
||||
}
|
||||
let Some(path) = path else { return Err("missing `path`".into()) };
|
||||
let result = if id == "library.inspectLightroom" {
|
||||
start_inspect(app, path, ctx)
|
||||
} else {
|
||||
start_import(app, path, p.get("updateExisting").and_then(Value::as_bool).unwrap_or(false), ctx)
|
||||
}?;
|
||||
if wait {
|
||||
return wait_for(app, ctx, std::time::Duration::from_secs(600));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll owner-thread work, commit a prepared import, and finish its archive index.
|
||||
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut task) = app.lightroom.take() else { return };
|
||||
let message = match task.rx.try_recv() {
|
||||
Ok(m) => m,
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
app.lightroom = Some(task);
|
||||
return;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
terminal(app, ctx, task.kind, json!({"error": "Lightroom worker stopped unexpectedly"}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match message {
|
||||
Message::Inspected(result) => match result {
|
||||
Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, task.kind, report),
|
||||
Ok(_) => terminal(app, ctx, task.kind, json!({"cancelled": true})),
|
||||
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, task.kind, json!({"cancelled": true})),
|
||||
Err(e) => terminal(app, ctx, task.kind, json!({"error": e})),
|
||||
},
|
||||
Message::Prepared(result) => {
|
||||
let prepared = match result {
|
||||
Ok(p) => p,
|
||||
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => {
|
||||
terminal(app, ctx, task.kind, json!({"cancelled": true}));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
terminal(app, ctx, task.kind, json!({"error": e}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if task.cancel.load(Ordering::Relaxed) || prepared.is_cancelled() || !prepared.token().matches(&app.session) {
|
||||
prepared.rollback();
|
||||
terminal(
|
||||
app,
|
||||
ctx,
|
||||
task.kind,
|
||||
if task.cancel.load(Ordering::Relaxed) {
|
||||
json!({"cancelled": true})
|
||||
} else {
|
||||
json!({"error": "Lightroom import belongs to a different open library"})
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
task.phase = Phase::Committing;
|
||||
let mut finalization = None;
|
||||
let mut report = None;
|
||||
let committed = app.session.execute_fn("library.importLightroom", |s| {
|
||||
let completion = lightcraft_engine::lightroom_job::commit_prepared(s, *prepared)?;
|
||||
report = Some(completion.report.clone());
|
||||
finalization = Some(completion.finalization);
|
||||
Ok(completion.report)
|
||||
});
|
||||
let report = report.unwrap_or(Value::Null);
|
||||
task.phase = Phase::Finalizing;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let commit_error = committed.as_ref().err().map(ToString::to_string);
|
||||
let Some(finalization) = finalization else {
|
||||
terminal(app, ctx, task.kind, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));
|
||||
return;
|
||||
};
|
||||
if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) {
|
||||
let mut result = json!({"report": report, "indexWarning": e});
|
||||
if let Some(error) = commit_error {
|
||||
result["error"] = json!(error);
|
||||
}
|
||||
terminal(app, ctx, task.kind, result);
|
||||
return;
|
||||
}
|
||||
task.rx = rx;
|
||||
task.cancelled = false;
|
||||
let mut pending = json!({"running": true, "kind": "import", "report": report});
|
||||
if let Some(error) = commit_error {
|
||||
pending["error"] = json!(error);
|
||||
}
|
||||
app.lightroom_last = Some(pending);
|
||||
app.lightroom = Some(task);
|
||||
}
|
||||
Message::Finalized(result) => {
|
||||
let pending = app.lightroom_last.take().unwrap_or(Value::Null);
|
||||
let report = pending.get("report").cloned().unwrap_or(Value::Null);
|
||||
match result {
|
||||
Ok(()) if pending.get("error").is_none() => terminal(app, ctx, task.kind, report),
|
||||
Ok(()) => terminal(app, ctx, task.kind, json!({"report": report, "error": pending["error"].clone()})),
|
||||
Err(e) => {
|
||||
let mut value = json!({"report": report, "indexWarning": e});
|
||||
if let Some(error) = pending.get("error") {
|
||||
value["error"] = error.clone();
|
||||
}
|
||||
terminal(app, ctx, task.kind, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Wait for a task in control/headless mode while still applying owner-thread completion.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait_for(app: &mut LightcraftApp, ctx: &egui::Context, timeout: std::time::Duration) -> Result<Value, String> {
|
||||
let start = std::time::Instant::now();
|
||||
while app.lightroom.is_some() {
|
||||
tick(app, ctx);
|
||||
if start.elapsed() >= timeout {
|
||||
return Err("timed out waiting for Lightroom task".into());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
Ok(app.lightroom_last.clone().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::time::Duration) -> Result<Value, String> {
|
||||
unsupported_wasm()
|
||||
}
|
||||
|
||||
/// The progress window for inspect/import, with cancellation.
|
||||
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = app.lightroom.as_ref() else { return };
|
||||
let total = task.total.load(Ordering::Relaxed);
|
||||
let done = task.done.load(Ordering::Relaxed);
|
||||
let title = match task.kind {
|
||||
Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"),
|
||||
Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"),
|
||||
};
|
||||
let text = match task.phase {
|
||||
Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(),
|
||||
Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total),
|
||||
Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(),
|
||||
Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(),
|
||||
};
|
||||
let mut cancel = false;
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0));
|
||||
let r =
|
||||
ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel")));
|
||||
register(ui.ctx(), "button:lightroomCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
},
|
||||
);
|
||||
if cancel {
|
||||
if let Some(task) = app.lightroom.as_mut() {
|
||||
task.cancel();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if app.lightroom.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::inspect_report;
|
||||
use lightcraft_engine::lightroom_catalog::CatalogImport;
|
||||
|
||||
#[test]
|
||||
fn inspect_report_keeps_empty_catalog_shape() {
|
||||
let report = inspect_report(CatalogImport {
|
||||
source: "synthetic.lrcat".into(),
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: vec!["warning".into()],
|
||||
});
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["collections"], 0);
|
||||
assert_eq!(report["missing"], serde_json::json!([]));
|
||||
assert_eq!(report["warnings"], serde_json::json!(["warning"]));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
|
||||
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
|
||||
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
|
||||
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
|
||||
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
|
||||
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
|
||||
];
|
||||
@@ -37,6 +38,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
|
||||
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
|
||||
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
|
||||
"app.language.portuguese" => Some(crate::i18n::Locale::PtBr),
|
||||
"app.language.spanish" => Some(crate::i18n::Locale::Es),
|
||||
"app.language.german" => Some(crate::i18n::Locale::De),
|
||||
"app.language.russian" => Some(crate::i18n::Locale::Ru),
|
||||
_ => None,
|
||||
@@ -148,6 +150,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"),
|
||||
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
|
||||
("file.addFolder", "Import from Folder…", None, "File"),
|
||||
("file.importLightroom", "Import Lightroom Catalog…", None, "File"),
|
||||
("file.addFromDevice", "Import from Device", None, ""),
|
||||
("file.findMissing", "Find Missing Photos…", None, "File"),
|
||||
("file.backupLibrary", "Back Up Library…", None, "File"),
|
||||
@@ -173,6 +176,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("app.github", "LightCraft on GitHub", None, "Help"),
|
||||
("app.artcraft", "ArtCraft Website", None, "Help"),
|
||||
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
|
||||
("app.setShortcut", "Set Keyboard Shortcut", None, ""),
|
||||
("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""),
|
||||
("app.export", "Export Now", None, ""),
|
||||
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
|
||||
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
|
||||
@@ -240,6 +245,10 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
||||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "library.inspectLightroom" | "library.importLightroom") {
|
||||
let ctx = egui::Context::default();
|
||||
return Some(crate::lightroom_import::command(app, id, p, &ctx));
|
||||
}
|
||||
if let Some(language) = language_from_command(id) {
|
||||
app.ui.language = language;
|
||||
// Immediately, not on the next frame: the reply and anything else run this frame
|
||||
@@ -976,12 +985,23 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::Shortcuts);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.setShortcut" => crate::shortcuts::set_shortcut(app, p),
|
||||
"app.resetShortcuts" => {
|
||||
app.ui.settings.keymap.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"library.browse" if !cfg!(target_arch = "wasm32") => {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Some(Err("finish Lightroom catalog import before browsing folders".into()));
|
||||
}
|
||||
// listed and read in the background (see `import::browse`)
|
||||
let path = p.get("path").and_then(Value::as_str)?;
|
||||
crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool))
|
||||
}
|
||||
"file.addPhotos" => {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Some(Err("finish Lightroom catalog import before adding photos".into()));
|
||||
}
|
||||
let paths = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(),
|
||||
@@ -992,6 +1012,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
// review first: the import dialog lists what was found
|
||||
crate::import::open(app, paths)
|
||||
}
|
||||
"file.importLightroom" => {
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f()));
|
||||
match path {
|
||||
Some(path) => app.run("library.importLightroom", json!({"path":path})),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
app.ui.quit = true;
|
||||
Ok(Value::Null)
|
||||
@@ -1319,7 +1347,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app),
|
||||
"file.backupLibrary" => app.services.backup_library.is_some(),
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
|
||||
@@ -1347,7 +1375,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
menu: m.split('>').map(str::to_string).collect(),
|
||||
shortcut: sc.map(str::to_string),
|
||||
shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string),
|
||||
enabled: ui_enabled(app, id),
|
||||
})
|
||||
.collect();
|
||||
@@ -1357,7 +1385,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
||||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
menu: c.menu.iter().map(|s| s.to_string()).collect(),
|
||||
shortcut: c.shortcut.map(str::to_string),
|
||||
shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string),
|
||||
enabled: c.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
|
||||
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
|
||||
// (nothing to download from in this build: the dialog explains the manual install)
|
||||
Dialog::SamModel { .. } if sam_by_hand(&app.session.segmenter) => "Install the SAM 3 Model",
|
||||
Dialog::SamModel { .. } => "Download the SAM 3 Model?",
|
||||
Dialog::About => "About LightCraft",
|
||||
Dialog::Shortcuts => "Keyboard Shortcuts",
|
||||
@@ -778,50 +780,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
let grid_pick = *id == "panel.presets" && crate::shortcuts::library_grid(app);
|
||||
ui.label(crate::i18n::tr(if grid_pick { "Flag as Pick" } else { label }));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(if grid_pick { "photo.flag" } else { id }).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for sc in ["0–5", "Shift+0–5"] {
|
||||
ui.label(crate::i18n::tr("Set Rating"));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new("photo.rate").color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(crate::i18n::tr("Set Color Label"));
|
||||
ui.label("6–9");
|
||||
ui.label(egui::RichText::new("photo.label").color(t.text_dim));
|
||||
ui.end_row();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut {
|
||||
ui.label(crate::i18n::tr(c.label));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new(c.id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for (sc, id, _) in crate::shortcuts::ALIASES {
|
||||
let label = crate::menus::ui_commands()
|
||||
.find(|c| c.0 == *id)
|
||||
.map(|c| c.1)
|
||||
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
|
||||
.unwrap_or(id);
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t),
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -829,7 +788,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let sam = &app.session.segmenter;
|
||||
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
|
||||
// no download location in this build: nothing to offer but the manual install
|
||||
let sam_nowhere = !sam_installed && !sam_running && sam.mirrors().is_empty();
|
||||
let sam_nowhere = sam_by_hand(sam);
|
||||
let cancel = match &dlg {
|
||||
Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close",
|
||||
Dialog::SamModel { .. } => "Not Now",
|
||||
@@ -880,6 +839,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ctx.move_to_top(w.response.layer_id);
|
||||
crate::widgets::register(ctx, "dialog:window", w.response.rect);
|
||||
}
|
||||
// (while the shortcuts editor records a key, it takes Esc itself to cancel)
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
@@ -925,6 +885,22 @@ pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool {
|
||||
matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed()
|
||||
}
|
||||
|
||||
/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is
|
||||
/// all the dialog can offer.
|
||||
fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool {
|
||||
!sam.installed() && !sam.download_status().running && sam.mirrors().is_empty()
|
||||
}
|
||||
|
||||
/// The model installation guide.
|
||||
const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model";
|
||||
|
||||
/// Show the SAM 3 model folder in the file manager (created first, so there is something to show).
|
||||
fn show_model_folder(app: &mut LightcraftApp, dir: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
|
||||
reveal(&dir.to_string_lossy())
|
||||
}
|
||||
|
||||
/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress.
|
||||
fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) {
|
||||
use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES};
|
||||
@@ -971,12 +947,32 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str
|
||||
return;
|
||||
}
|
||||
if seg.mirrors().is_empty() {
|
||||
// nothing to download from: say how to install it by hand, with the folder and the guide a click away
|
||||
let dir = seg.dir.clone();
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr(
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).",
|
||||
"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.",
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if let Some(dir) = dir.filter(|_| app.services.reveal.is_some()) {
|
||||
let r = ui.button(crate::i18n::tr(crate::menus::reveal_label()));
|
||||
crate::widgets::register(ui.ctx(), "button:samFolder", r.rect);
|
||||
if r.clicked()
|
||||
&& let Err(e) = show_model_folder(app, &dir)
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP);
|
||||
crate::widgets::register(ui.ctx(), "link:samHelp", r.rect);
|
||||
if r.clicked()
|
||||
&& let Err(e) = crate::links::open(app, SAM_HELP)
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(e) = error.map(str::to_string).or(d.error) {
|
||||
ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80)));
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Help ▸ Keyboard Shortcuts (⌘/): every command's shortcut, editable.
|
||||
//!
|
||||
//! Click a shortcut, then press the new keys (Esc cancels); × removes it, ↺ restores the declared
|
||||
//! one. A key another command had moves to the new command. Changes go through
|
||||
//! [`crate::shortcuts::assign`] into [`crate::state::AppSettings::keymap`] (saved in `ui.json`),
|
||||
//! the same path as the `app.setShortcut` command.
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::shortcuts::{self, Bindable};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
const SEARCH: &str = "shortcuts-search";
|
||||
const SHOW_ALL: &str = "shortcuts-show-all";
|
||||
|
||||
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
capture(app, ui.ctx());
|
||||
ui.set_min_width(560.0);
|
||||
let (search_id, all_id) = (egui::Id::new(SEARCH), egui::Id::new(SHOW_ALL));
|
||||
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
let mut show_all: bool = ui.data(|d| d.get_temp(all_id)).unwrap_or(false);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut search).hint_text(crate::i18n::tr("Search commands or keys")).desired_width(220.0));
|
||||
register(ui.ctx(), "field:shortcutsSearch", r.rect);
|
||||
let r = ui.checkbox(&mut show_all, crate::i18n::tr("Show commands without a shortcut"));
|
||||
register(ui.ctx(), "check:shortcuts.showAll", r.rect);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add_enabled(!app.ui.settings.keymap.is_empty(), egui::Button::new(crate::i18n::tr("Reset All")));
|
||||
register(ui.ctx(), "button:shortcutsResetAll", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.resetShortcuts", serde_json::json!({}));
|
||||
app.recording_shortcut = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(search_id, search.clone());
|
||||
d.insert_temp(all_id, show_all);
|
||||
});
|
||||
ui.label(RichText::new(crate::i18n::tr("Click a shortcut, then press the new keys (Esc cancels).")).size(11.0).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
|
||||
let needle = search.trim().to_lowercase();
|
||||
let mut rows: Vec<(&'static Bindable, Option<String>)> = shortcuts::bindable()
|
||||
.iter()
|
||||
.map(|b| (b, shortcuts::binding(&app.ui.settings.keymap, b.id, b.default).map(str::to_string)))
|
||||
.filter(|(b, sc)| show_all || sc.is_some() || app.recording_shortcut.as_deref() == Some(b.id))
|
||||
.filter(|(b, sc)| {
|
||||
needle.is_empty()
|
||||
|| crate::i18n::tr(b.label).to_lowercase().contains(&needle)
|
||||
|| b.id.to_lowercase().contains(&needle)
|
||||
|| sc.as_deref().is_some_and(|sc| sc.to_lowercase().contains(&needle) || menu_text(sc, mac).to_lowercase().contains(&needle))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase());
|
||||
|
||||
// a fixed height, so the dialog doesn't jump around while the search narrows the list
|
||||
egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (b, sc) in &rows {
|
||||
row(app, ui, t, b, sc.as_deref(), mac);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ui.label(RichText::new(crate::i18n::tr("No matching commands")).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
fixed_keys(app, ui, t, mac, &needle);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS).
|
||||
fn menu_text(sc: &str, mac: bool) -> String {
|
||||
crate::menubar::shortcut_text(sc, mac)
|
||||
}
|
||||
|
||||
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) {
|
||||
let changed = app.ui.settings.keymap.contains_key(b.id);
|
||||
let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text);
|
||||
if changed {
|
||||
name = name.strong();
|
||||
}
|
||||
// keys whose command changes in the Library grids (see `shortcuts::handle`)
|
||||
let hover = match b.id {
|
||||
"panel.presets" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: Flag as Pick and advance")),
|
||||
"view.softProof" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: expand or collapse the stack")),
|
||||
_ => b.id.to_string(),
|
||||
};
|
||||
ui.label(name).on_hover_text(hover);
|
||||
let recording = app.recording_shortcut.as_deref() == Some(b.id);
|
||||
let text = if recording {
|
||||
RichText::new(crate::i18n::tr("Press keys…")).color(egui::Color32::WHITE)
|
||||
} else {
|
||||
match sc {
|
||||
Some(sc) => RichText::new(menu_text(sc, mac)).color(t.text),
|
||||
None => RichText::new("—").color(t.text_dim),
|
||||
}
|
||||
};
|
||||
let r = ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0)).selected(recording));
|
||||
register(ui.ctx(), format!("button:shortcut-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
app.recording_shortcut = if recording { None } else { Some(b.id.to_string()) };
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let r = ui.add_enabled(sc.is_some(), egui::Button::new("×").small()).on_hover_text(crate::i18n::tr("Remove shortcut"));
|
||||
register(ui.ctx(), format!("button:shortcutClear-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null}));
|
||||
}
|
||||
let r = ui.add_enabled(changed, egui::Button::new("↺").small()).on_hover_text(match b.default {
|
||||
Some(d) => crate::i18n::tr_format!("Restore {}", menu_text(d, mac)),
|
||||
None => crate::i18n::tr("Restore (no shortcut)").to_string(),
|
||||
});
|
||||
register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
apply(app, ui.ctx(), serde_json::json!({"id": b.id, "reset": true}));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(b.id).size(10.5).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// Keys that aren't a command's own shortcut (ratings, labels, secondary keys): listed, not editable.
|
||||
/// One the user gave to a command shows struck through.
|
||||
fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, needle: &str) {
|
||||
let mut fixed: Vec<(String, &str)> = Vec::new();
|
||||
for (sc, id, _) in shortcuts::ALIASES {
|
||||
let label = shortcuts::find_bindable(id).map_or(*id, |b| b.label);
|
||||
fixed.push((sc.to_string(), label));
|
||||
}
|
||||
for n in 0..=5 {
|
||||
fixed.push((n.to_string(), ["Clear rating", "Rate ★", "Rate ★★", "Rate ★★★", "Rate ★★★★", "Rate ★★★★★"][n]));
|
||||
fixed.push((
|
||||
format!("Shift+{n}"),
|
||||
[
|
||||
"Clear rating and advance",
|
||||
"Rate ★ and advance",
|
||||
"Rate ★★ and advance",
|
||||
"Rate ★★★ and advance",
|
||||
"Rate ★★★★ and advance",
|
||||
"Rate ★★★★★ and advance",
|
||||
][n],
|
||||
));
|
||||
}
|
||||
for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] {
|
||||
fixed.push((sc.to_string(), label));
|
||||
}
|
||||
let fixed: Vec<_> = fixed
|
||||
.into_iter()
|
||||
.filter(|(sc, label)| {
|
||||
needle.is_empty() || crate::i18n::tr(label).to_lowercase().contains(needle) || menu_text(sc, mac).to_lowercase().contains(needle)
|
||||
})
|
||||
.collect();
|
||||
if fixed.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text));
|
||||
let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect();
|
||||
egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (sc, label) in fixed {
|
||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
||||
let mut text = RichText::new(menu_text(&sc, mac)).color(t.text_dim);
|
||||
if shortcuts::parse(&sc).is_some_and(|k| taken.contains(&k)) {
|
||||
text = text.strikethrough();
|
||||
}
|
||||
ui.label(text);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `app.setShortcut` and say what changed (a key taken from another command, or why not).
|
||||
fn apply(app: &mut LightcraftApp, ctx: &egui::Context, params: serde_json::Value) {
|
||||
match app.run("app.setShortcut", params) {
|
||||
Ok(r) => {
|
||||
let lost: Vec<String> = r["removedFrom"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|id| shortcuts::find_bindable(id).map_or(id, |b| crate::i18n::tr(b.label)).to_string())
|
||||
.collect();
|
||||
if !lost.is_empty() {
|
||||
app.toast_for(ctx, crate::i18n::tr_format!("Removed from {}", lost.join(", ")), 3.0);
|
||||
}
|
||||
}
|
||||
Err(e) => app.toast_for(ctx, e, 3.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// While a row is recording: the first key press becomes its shortcut, Esc cancels.
|
||||
fn capture(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.recording_shortcut.clone() else { return };
|
||||
let pressed = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
// ⌘, ⇧, ⌥ and ⌃ on their own arrive as keys too: keep waiting for the key they modify
|
||||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !shortcuts::is_modifier(*key) => Some((*key, *modifiers)),
|
||||
// the windowing layer turns ⌘C / ⌘X / ⌘V (with or without other modifiers) into these
|
||||
egui::Event::Copy => Some((egui::Key::C, i.modifiers)),
|
||||
egui::Event::Cut => Some((egui::Key::X, i.modifiers)),
|
||||
egui::Event::Paste(_) => Some((egui::Key::V, i.modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let Some((key, modifiers)) = pressed else { return };
|
||||
// the key press is ours: nothing else (a focused button's Space, Tab navigation) acts on it
|
||||
ctx.input_mut(|i| {
|
||||
i.events
|
||||
.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)));
|
||||
});
|
||||
app.recording_shortcut = None;
|
||||
if key == egui::Key::Escape && modifiers.is_none() {
|
||||
return;
|
||||
}
|
||||
match shortcuts::format(modifiers, key) {
|
||||
Some(sc) => apply(app, ctx, serde_json::json!({"id": id, "shortcut": sc})),
|
||||
None => app.toast_for(ctx, crate::i18n::tr("That key can't be used as a shortcut"), 3.0),
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,11 @@ fn open(app: &mut LightcraftApp, path: Option<String>) {
|
||||
Ok(serde_json::Value::Null) => {} // folder dialog cancelled
|
||||
Ok(_) => {
|
||||
let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
let _ = app.run("library.import", json!({"paths": files}));
|
||||
// in the background, like files dropped on the window (issue #374)
|
||||
if !files.is_empty()
|
||||
&& let Err(e) = crate::import::start_paths(app, files)
|
||||
{
|
||||
log::warn!("import: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -144,8 +147,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
p.dismissed = true;
|
||||
}
|
||||
let files = app.library_problem.as_mut().map(|p| std::mem::take(&mut p.pending_import)).unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
let _ = app.run("library.import", json!({"paths": files}));
|
||||
// in the background, like files dropped on the window (issue #374)
|
||||
if !files.is_empty()
|
||||
&& let Err(e) = crate::import::start_paths(app, files)
|
||||
{
|
||||
log::warn!("import: {e}");
|
||||
}
|
||||
}
|
||||
Some(Choice::Quit) => app.ui.quit = true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The Masking panel: create masks, list them, edit components and local adjustments.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Rect, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};
|
||||
use serde_json::json;
|
||||
@@ -10,7 +10,7 @@ use super::right::header;
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{divider, icon_button, register, slider, text_button};
|
||||
use crate::widgets::{divider, icon_button, one_line, register, slider, text_button};
|
||||
|
||||
const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {
|
||||
ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }
|
||||
@@ -61,12 +61,29 @@ fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
|
||||
|
||||
const TILE: f32 = 52.0;
|
||||
const TILE_GAP: f32 = 6.0;
|
||||
/// Room left and right of a tile's label.
|
||||
const TILE_PAD: f32 = 3.0;
|
||||
|
||||
/// Columns and tile width of the Create New Mask grid in `width`: four tiles of up to 52 pt (at
|
||||
/// least 48, so the labels fit), else three (narrowed if even those don't fit).
|
||||
fn tile_layout(width: f32) -> (usize, f32) {
|
||||
let tile = |n: f32| ((width - (n - 1.0) * TILE_GAP) / n).floor().min(TILE);
|
||||
if tile(4.0) >= 48.0 { (4, tile(4.0)) } else { (3, tile(3.0).max(24.0)) }
|
||||
/// Columns, tile width and label size of the Create New Mask grid in `width`, `label(size)` being
|
||||
/// the widest label at a font size: four tiles of up to 52 pt (at least 48), else three, else two —
|
||||
/// the first that fits the labels at 10.5 pt, or at 9.5 pt before a column is dropped. A tile grows
|
||||
/// past 52 pt for a long (translated) label. In a panel too narrow even for that, two tiles whose
|
||||
/// labels are cut short.
|
||||
fn tile_layout(width: f32, label: impl Fn(f32) -> f32) -> (usize, f32, f32) {
|
||||
let room = |n: usize| ((width - (n as f32 - 1.0) * TILE_GAP) / n as f32).floor();
|
||||
for n in [4, 3, 2] {
|
||||
let w = room(n);
|
||||
if n == 4 && w < 48.0 {
|
||||
continue;
|
||||
}
|
||||
for size in [10.5, 9.5] {
|
||||
let need = (label(size) + 2.0 * TILE_PAD).ceil();
|
||||
if need <= w {
|
||||
return (n, w.min(TILE.max(need)), size);
|
||||
}
|
||||
}
|
||||
}
|
||||
(2, room(2).max(24.0), 9.5)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
@@ -88,15 +105,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
("luminanceRange", "Luminance", Icon::Sliders),
|
||||
("colorRange", "Color", Icon::Picker),
|
||||
];
|
||||
// four 52 pt tiles a row when they fit, else as many as fit (at least three, shrunk)
|
||||
let (cols, tile) = tile_layout(ui.available_width());
|
||||
// four 52 pt tiles a row when they and their labels fit, else fewer (wider for a long label)
|
||||
let widest = |size: f32| {
|
||||
let font = t.font(size);
|
||||
tiles
|
||||
.iter()
|
||||
.map(|(_, l, _)| ui.painter().layout_no_wrap(crate::i18n::tr(l).to_string(), font.clone(), t.text_dim).size().x)
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
let (cols, tile, size) = tile_layout(ui.available_width(), widest);
|
||||
egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| {
|
||||
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click());
|
||||
register(ui.ctx(), format!("maskNew:{kind}"), r);
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
|
||||
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
|
||||
ui.painter().text(pos2(r.center().x, r.bottom() - 9.0), Align2::CENTER_CENTER, *label, t.font(10.5), t.text_dim);
|
||||
// (cut short only in a panel too narrow for the label: the full name on hover)
|
||||
let label = crate::i18n::tr(label);
|
||||
let g = one_line(ui.painter(), label, t.font(size), t.text_dim, tile - 2.0 * TILE_PAD);
|
||||
let elided = g.elided;
|
||||
let at = pos2(r.center().x, r.bottom() - 9.0) - g.size() / 2.0;
|
||||
register(ui.ctx(), format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size()));
|
||||
ui.painter().galley(at, g, t.text_dim);
|
||||
let resp = if elided { resp.on_hover_text(label) } else { resp };
|
||||
if resp.clicked() {
|
||||
match *kind {
|
||||
"colorRange" => {
|
||||
@@ -136,7 +167,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
};
|
||||
if let Some(status) = status {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim));
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap());
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
// the model download, while its dialog is closed
|
||||
@@ -207,13 +238,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
);
|
||||
let icon = m.components.first().map(|c| kind_label(&c.shape).1).unwrap_or(Icon::Mask);
|
||||
paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, t.text_label);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 32.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&m.name,
|
||||
t.font(13.0),
|
||||
if m.visible { t.text } else { t.text_disabled },
|
||||
);
|
||||
// a long name is cut short (with …) before the eye, the full name on hover
|
||||
let color = if m.visible { t.text } else { t.text_disabled };
|
||||
let g = one_line(ui.painter(), &m.name, t.font(13.0), color, r.width() - 32.0 - 32.0);
|
||||
let elided = g.elided;
|
||||
let at = pos2(r.left() + 32.0, r.center().y - g.size().y / 2.0);
|
||||
register(ui.ctx(), format!("maskName:{}", m.id), Rect::from_min_size(at, g.size()));
|
||||
ui.painter().galley(at, g, color);
|
||||
let resp = if elided { resp.on_hover_text(&m.name) } else { resp };
|
||||
// show / hide on hover (and always while hidden)
|
||||
// (the pointer test, not `hovered`: over the eye, the row itself no longer counts as hovered)
|
||||
if ui.rect_contains_pointer(r) || !m.visible {
|
||||
@@ -706,11 +738,15 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
};
|
||||
ui.label(crate::i18n::tr(prompt));
|
||||
let mut submit = false;
|
||||
ui.horizontal(|ui| {
|
||||
// the (translated) Select button is placed first, at the right; the field takes what is left
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
|
||||
submit = true;
|
||||
}
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut text)
|
||||
.hint_text(crate::i18n::tr("e.g. sky · the red car · car, road"))
|
||||
.desired_width(ui.available_width() - 64.0),
|
||||
.desired_width(ui.available_width()),
|
||||
);
|
||||
register(ui.ctx(), "maskDescribe", r.rect);
|
||||
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
|
||||
@@ -719,9 +755,6 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
submit = true;
|
||||
}
|
||||
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
|
||||
submit = true;
|
||||
}
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.describe = None;
|
||||
@@ -813,3 +846,32 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
|
||||
/// room by dropping a column rather than drawing it past its tile.
|
||||
#[test]
|
||||
fn tile_labels_get_the_room_they_need() {
|
||||
let short = |size: f32| size * 4.0; // "Luminance"-ish: 42 pt at 10.5
|
||||
let long = |size: f32| size * 5.6; // "Background"/"Hintergrund": 59 pt at 10.5, 53 at 9.5
|
||||
assert_eq!(tile_layout(224.0, short), (4, 51.0, 10.5));
|
||||
// too wide for four at either size: three tiles, wider than 52 so the label fits
|
||||
let (n, w, size) = tile_layout(224.0, long);
|
||||
assert_eq!((n, size), (3, 10.5));
|
||||
assert!(w >= long(10.5) + 2.0 * TILE_PAD && w <= (224.0 - 2.0 * TILE_GAP) / 3.0, "{w}");
|
||||
// a smaller label keeps a column: 9.5 pt fits where 10.5 doesn't
|
||||
let mid = |size: f32| size * 4.6; // 48.3 at 10.5, 43.7 at 9.5
|
||||
assert_eq!(tile_layout(224.0, mid), (4, 51.0, 9.5));
|
||||
// a wide panel: tiles stay 52 pt (or as wide as the label)
|
||||
assert_eq!(tile_layout(474.0, short).0, 4);
|
||||
assert_eq!(tile_layout(474.0, short).1, TILE);
|
||||
// far too narrow for any label: two tiles (labels cut short), never a negative width
|
||||
let (n, w, _) = tile_layout(60.0, long);
|
||||
assert_eq!(n, 2);
|
||||
assert!(w >= 24.0);
|
||||
assert_eq!(tile_layout(f32::NAN, long).0, 2);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod dialogs;
|
||||
pub mod edit;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod keymap;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod masking;
|
||||
|
||||
@@ -543,6 +543,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
|
||||
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
|
||||
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Err("wait for Lightroom catalog import to finish before switching libraries".into());
|
||||
}
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => x.to_string(),
|
||||
None => match app.services.pick_folder.as_mut() {
|
||||
@@ -555,6 +558,7 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
|
||||
};
|
||||
app.session.close_library().map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
|
||||
app.lightroom_last = None;
|
||||
app.renderer.forget_all();
|
||||
app.ui.compare = None;
|
||||
app.ui.settings.library_path = path.clone();
|
||||
|
||||
+291
-39
@@ -1,10 +1,195 @@
|
||||
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands.
|
||||
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings, apply the user's keymap (Help ▸
|
||||
//! Keyboard Shortcuts, `app.setShortcut`) and dispatch UI and engine commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
|
||||
/// The user's changes to the keymap: command id → shortcut (`""` = no shortcut). Saved with the
|
||||
/// app settings (`ui.json`); commands not listed keep their declared shortcut.
|
||||
pub type Keymap = BTreeMap<String, String>;
|
||||
|
||||
/// Shortcuts the app menu owns (Settings…, Quit), which no command can take.
|
||||
pub const RESERVED: &[&str] = &["Cmd+,", "Cmd+Q"];
|
||||
|
||||
/// A command that can have a shortcut.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Bindable {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// The declared shortcut. An engine command whose key a UI command wraps (e.g. `W` opens the
|
||||
/// White Balance Selector rather than sampling without a point) has none: the UI one owns it.
|
||||
pub default: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Every command that can have a shortcut: UI commands, then engine commands (one entry per id).
|
||||
pub fn bindable() -> &'static [Bindable] {
|
||||
static ALL: OnceLock<Vec<Bindable>> = OnceLock::new();
|
||||
ALL.get_or_init(|| {
|
||||
let mut v: Vec<Bindable> = Vec::new();
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if !v.iter().any(|b| b.id == *id) {
|
||||
v.push(Bindable { id, label, default: *sc });
|
||||
}
|
||||
}
|
||||
let ui_keys: Vec<(Modifiers, Key)> = v.iter().filter_map(|b| b.default.and_then(parse)).collect();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if v.iter().any(|b| b.id == c.id) {
|
||||
continue;
|
||||
}
|
||||
let default = c.shortcut.filter(|sc| parse(sc).is_some_and(|k| !ui_keys.contains(&k)));
|
||||
v.push(Bindable { id: c.id, label: c.label, default });
|
||||
}
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_bindable(id: &str) -> Option<&'static Bindable> {
|
||||
bindable().iter().find(|b| b.id == id)
|
||||
}
|
||||
|
||||
/// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry
|
||||
/// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is
|
||||
/// ignored.
|
||||
pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> {
|
||||
match keymap.get(id) {
|
||||
Some(sc) if sc.is_empty() => None,
|
||||
Some(sc) if parse(sc).is_some() => Some(sc.as_str()),
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective shortcut of command `id` (`None` for unknown commands).
|
||||
pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> {
|
||||
find_bindable(id).and_then(|b| binding(keymap, id, b.default))
|
||||
}
|
||||
|
||||
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
|
||||
pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
|
||||
|
||||
/// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted).
|
||||
pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> {
|
||||
let Some(key) = parse(sc) else { return vec![] };
|
||||
bindable()
|
||||
.iter()
|
||||
.filter(|b| b.id != id && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == b.id) || (*y == id && *x == b.id)))
|
||||
.filter(|b| binding(keymap, b.id, b.default).and_then(parse) == Some(key))
|
||||
.map(|b| b.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Give `id` the shortcut `sc` (`None` = no shortcut), taking it away from commands that had the
|
||||
/// same key. Returns the commands that lost it.
|
||||
pub fn assign(keymap: &mut Keymap, id: &str, sc: Option<&str>) -> Result<Vec<&'static str>, String> {
|
||||
let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?;
|
||||
let sc = match sc.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let key = parse(s).ok_or_else(|| format!("not a shortcut: {s}"))?;
|
||||
// store the canonical spelling so equal keys compare equal (menus, native accelerators)
|
||||
let canonical = format(key.0, key.1).unwrap_or_else(|| s.to_string());
|
||||
if RESERVED.iter().any(|r| parse(r) == Some(key)) {
|
||||
return Err(format!("{canonical} is reserved for the app menu"));
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
};
|
||||
let lost = sc.as_deref().map(|s| conflicts(keymap, id, s)).unwrap_or_default();
|
||||
for other in &lost {
|
||||
set(keymap, other, None);
|
||||
}
|
||||
set(keymap, b.id, sc.as_deref());
|
||||
Ok(lost)
|
||||
}
|
||||
|
||||
/// Store `sc` for `id`, dropping the entry when it equals the declared shortcut.
|
||||
fn set(keymap: &mut Keymap, id: &str, sc: Option<&str>) {
|
||||
let default = find_bindable(id).and_then(|b| b.default);
|
||||
let same = match (sc, default) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => parse(a) == parse(b),
|
||||
_ => false,
|
||||
};
|
||||
if same {
|
||||
keymap.remove(id);
|
||||
} else {
|
||||
keymap.insert(id.to_string(), sc.unwrap_or_default().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the declared shortcut of `id`, taking it away from a command the user gave it to.
|
||||
pub fn reset(keymap: &mut Keymap, id: &str) -> Result<Vec<&'static str>, String> {
|
||||
let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?;
|
||||
assign(keymap, id, b.default)
|
||||
}
|
||||
|
||||
/// `app.setShortcut {id, shortcut?, reset?}`: `shortcut` null or `""` removes it.
|
||||
pub fn set_shortcut(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let id = p.get("id").and_then(Value::as_str).ok_or("missing id")?;
|
||||
let keymap = &mut app.ui.settings.keymap;
|
||||
let lost = if p.get("reset").and_then(Value::as_bool).unwrap_or(false) {
|
||||
reset(keymap, id)?
|
||||
} else {
|
||||
match p.get("shortcut") {
|
||||
None => return Err("missing shortcut (null removes it)".into()),
|
||||
Some(Value::Null) => assign(keymap, id, None)?,
|
||||
Some(Value::String(s)) => assign(keymap, id, Some(s))?,
|
||||
Some(_) => return Err("shortcut must be a string or null".into()),
|
||||
}
|
||||
};
|
||||
Ok(json!({"id": id, "shortcut": shortcut_of(&app.ui.settings.keymap, id), "removedFrom": lost}))
|
||||
}
|
||||
|
||||
/// ⌘ / ⇧ / ⌥ / ⌃ themselves: they modify a shortcut's key, they can't be it.
|
||||
pub fn is_modifier(k: Key) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight
|
||||
)
|
||||
}
|
||||
|
||||
/// The shortcut text for a key press (`Cmd+Shift+K`), or `None` for keys a shortcut can't name.
|
||||
/// `Cmd` is ⌘ on macOS and Ctrl elsewhere; `Ctrl` is the macOS Control key.
|
||||
pub fn format(m: Modifiers, k: Key) -> Option<String> {
|
||||
if is_modifier(k) {
|
||||
return None;
|
||||
}
|
||||
let key = match k {
|
||||
Key::Backspace | Key::Delete => "Delete",
|
||||
Key::Slash => "/",
|
||||
Key::Backslash => "\\",
|
||||
Key::Equals => "=",
|
||||
Key::Minus => "-",
|
||||
Key::OpenBracket => "[",
|
||||
Key::CloseBracket => "]",
|
||||
Key::Quote => "'",
|
||||
Key::Comma => ",",
|
||||
k => k.name(),
|
||||
};
|
||||
let mut s = String::new();
|
||||
if m.command || m.mac_cmd {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
// off macOS Ctrl *is* Cmd; on macOS it is the separate Control key
|
||||
if m.ctrl && (m.mac_cmd || !m.command) {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
if m.shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
s.push_str(key);
|
||||
let back = parse(&s)?;
|
||||
let want = if k == Key::Delete { Key::Backspace } else { k };
|
||||
(back.1 == want).then_some(s)
|
||||
}
|
||||
|
||||
/// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`.
|
||||
/// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our
|
||||
/// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts.
|
||||
@@ -48,21 +233,26 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
||||
// "Delete" means the key labelled ⌫ (egui's Backspace); forward-delete also matches, see `matches`.
|
||||
"Delete" => key = Some(Key::Backspace),
|
||||
k => {
|
||||
key = Key::from_name(k).or(match k {
|
||||
"Right" => Some(Key::ArrowRight),
|
||||
"Left" => Some(Key::ArrowLeft),
|
||||
"Up" => Some(Key::ArrowUp),
|
||||
"Down" => Some(Key::ArrowDown),
|
||||
"\\" => Some(Key::Backslash),
|
||||
"/" => Some(Key::Slash),
|
||||
"=" => Some(Key::Equals),
|
||||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
// an unknown part (`Hyper`, a typo) makes the whole shortcut invalid
|
||||
key = Some(
|
||||
Key::from_name(k)
|
||||
.or(match k {
|
||||
"Right" => Some(Key::ArrowRight),
|
||||
"Left" => Some(Key::ArrowLeft),
|
||||
"Up" => Some(Key::ArrowUp),
|
||||
"Down" => Some(Key::ArrowDown),
|
||||
"\\" => Some(Key::Backslash),
|
||||
"/" => Some(Key::Slash),
|
||||
"=" => Some(Key::Equals),
|
||||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|k| !is_modifier(*k))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,36 +282,33 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
// don't steal keys from text fields
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
|
||||
app.recording_shortcut = None;
|
||||
}
|
||||
// don't steal keys from text fields or from the keymap editor recording a shortcut
|
||||
if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut fire: Vec<String> = Vec::new();
|
||||
// shortcuts the native menu bar handles (it consumes those key presses itself)
|
||||
let native = |sc: &str| app.native_shortcuts.contains(sc);
|
||||
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
let keymap = &app.ui.settings.keymap;
|
||||
// keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them
|
||||
let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect();
|
||||
ctx.input(|i| {
|
||||
let mut ui_keys = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some((m, k)) = sc.and_then(parse) {
|
||||
ui_keys.push((m, k));
|
||||
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
|
||||
fire.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
// A UI command bound to the same key wraps the engine command (e.g. `W` opens the
|
||||
// White Balance Selector tool rather than sampling without a point): the UI one wins.
|
||||
if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse)
|
||||
&& !ui_keys.contains(&(m, k))
|
||||
for b in bindable() {
|
||||
if let Some(sc) = binding(keymap, b.id, b.default)
|
||||
&& let Some((m, k)) = parse(sc)
|
||||
&& !native(sc)
|
||||
&& matches(i, m, k)
|
||||
{
|
||||
fire.push(c.id.to_string());
|
||||
fire.push(b.id.to_string());
|
||||
}
|
||||
}
|
||||
for (sc, id, params) in ALIASES {
|
||||
if let Some((m, k)) = parse(sc).filter(|_| !native(sc))
|
||||
&& !taken.contains(&(m, k))
|
||||
&& matches(i, m, k)
|
||||
{
|
||||
aliased.push((id, serde_json::from_str(params).unwrap_or_default()));
|
||||
@@ -129,15 +316,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
// rating 0-5, colour labels 6-9 (with Shift: and advance)
|
||||
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
|
||||
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) {
|
||||
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) {
|
||||
fire.push(format!("rate:{n}:0"));
|
||||
}
|
||||
if matches(i, Modifiers::SHIFT, *key) {
|
||||
if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) {
|
||||
fire.push(format!("rate:{n}:1"));
|
||||
}
|
||||
}
|
||||
for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] {
|
||||
if matches(i, Modifiers::NONE, key) && !native(sc) {
|
||||
if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) {
|
||||
fire.push(format!("label:{label}"));
|
||||
}
|
||||
}
|
||||
@@ -554,8 +741,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
|
||||
const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
|
||||
#[test]
|
||||
fn key_presses_format_to_shortcuts_that_parse_back() {
|
||||
for k in Key::ALL {
|
||||
for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] {
|
||||
if let Some(sc) = format(m, *k) {
|
||||
let want = if *k == Key::Delete { Key::Backspace } else { *k };
|
||||
assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K"));
|
||||
assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/"));
|
||||
assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigning_a_key_moves_it_and_reset_restores_it() {
|
||||
let mut keymap = Keymap::new();
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N"));
|
||||
// D belongs to Detail: Survey takes it, Detail loses it
|
||||
let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap();
|
||||
assert_eq!(lost, vec!["view.detail"]);
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D"));
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), None);
|
||||
// restoring Detail takes D back
|
||||
let lost = reset(&mut keymap, "view.detail").unwrap();
|
||||
assert_eq!(lost, vec!["view.survey"]);
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D"));
|
||||
reset(&mut keymap, "view.survey").unwrap();
|
||||
assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}");
|
||||
// a command without a declared shortcut can get one, and lose it again
|
||||
assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err();
|
||||
assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap();
|
||||
assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1"));
|
||||
assign(&mut keymap, "view.photoGrid", None).unwrap();
|
||||
assert!(keymap.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_assignments_are_errors() {
|
||||
let mut keymap = Keymap::new();
|
||||
assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err());
|
||||
assert!(keymap.is_empty());
|
||||
}
|
||||
|
||||
/// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic.
|
||||
#[test]
|
||||
fn junk_keymap_entries_mean_no_shortcut() {
|
||||
let mut keymap = Keymap::new();
|
||||
keymap.insert("view.survey".into(), "Hyper+☃".into());
|
||||
keymap.insert("no.suchCommand".into(), "K".into());
|
||||
keymap.insert("view.detail".into(), "Cmd+SuperLeft".into());
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N"));
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D"));
|
||||
assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey"));
|
||||
assert_eq!(parse("Hyper+K"), None);
|
||||
}
|
||||
|
||||
/// Contextual partners keep sharing their key when one is reassigned.
|
||||
#[test]
|
||||
fn contextual_partners_are_not_conflicts() {
|
||||
let keymap = Keymap::new();
|
||||
assert!(conflicts(&keymap, "photo.reject", "X").is_empty());
|
||||
}
|
||||
|
||||
/// No key fires two different actions (a UI command may shadow the engine command it wraps).
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,8 @@ pub struct AppSettings {
|
||||
pub film_badges: bool,
|
||||
/// Grid: when to show the rating / flag / edited badges.
|
||||
pub grid_badges: GridBadges,
|
||||
/// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none.
|
||||
pub keymap: crate::shortcuts::Keymap,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -167,6 +169,7 @@ impl Default for AppSettings {
|
||||
film_names: true,
|
||||
film_badges: true,
|
||||
grid_badges: GridBadges::Auto,
|
||||
keymap: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Headless tests of the editable keymap (Help ▸ Keyboard Shortcuts): a changed shortcut fires
|
||||
//! its command and not the old one's, the editor records a key press without running it, Esc
|
||||
//! cancels recording without closing the dialog, and the keymap survives a save/load of `ui.json`.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::{Dialog, ViewMode};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn demo() -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut Headless, k: &str, shift: bool) {
|
||||
let r = h.request("ui.key", json!({"key": k, "shift": shift}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebound_shortcut_fires_and_the_old_key_does_not() {
|
||||
let mut h = demo();
|
||||
let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+K"})).unwrap();
|
||||
assert_eq!(r["shortcut"], "Shift+K", "{r}");
|
||||
// the old key (N) no longer opens Survey…
|
||||
key(&mut h, "N", false);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Detail);
|
||||
// …the new one does, and menus show it
|
||||
key(&mut h, "K", true);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Survey);
|
||||
let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "view.survey").unwrap();
|
||||
assert_eq!(entry.shortcut.as_deref(), Some("Shift+K"));
|
||||
// the keymap is saved with the UI state
|
||||
let saved = serde_json::to_string(&h.app.ui).unwrap();
|
||||
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap();
|
||||
assert_eq!(back.settings.keymap.get("view.survey").map(String::as_str), Some("Shift+K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_editor_records_a_key_and_esc_cancels_without_closing() {
|
||||
let mut h = demo();
|
||||
h.app.run("app.shortcuts", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
// the list is longer than the dialog: narrow it so the row is on screen
|
||||
click(&mut h, "field:shortcutsSearch");
|
||||
assert_eq!(h.request("ui.text", json!({"text": "compare"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
// record Shift+J for Compare: the key press is taken, not run
|
||||
click(&mut h, "button:shortcut-view.compare");
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.compare"));
|
||||
key(&mut h, "J", true);
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
|
||||
assert_eq!(h.app.ui.view, ViewMode::Detail);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
|
||||
// Esc while recording cancels; the dialog stays open and nothing changes
|
||||
click(&mut h, "button:shortcut-view.compare");
|
||||
key(&mut h, "Escape", false);
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
|
||||
// the next Esc closes it; the new key works
|
||||
key(&mut h, "Escape", false);
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
key(&mut h, "J", true);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Compare);
|
||||
// Reset All restores the declared keys
|
||||
h.app.run("app.resetShortcuts", json!({})).unwrap();
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_given_to_a_command_overrides_a_fixed_key() {
|
||||
let mut h = demo();
|
||||
let before = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
|
||||
// 3 (rate ★★★) now opens Survey instead
|
||||
h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "3"})).unwrap();
|
||||
key(&mut h, "3", false);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Survey);
|
||||
let after = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
|
||||
assert_eq!(before, after, "the rating key must not fire too");
|
||||
}
|
||||
|
||||
/// Open the editor, filtered to `search`, and start recording for `id`.
|
||||
fn record(h: &mut Headless, search: &str, id: &str) {
|
||||
if h.app.ui.dialog.is_none() {
|
||||
h.app.run("app.shortcuts", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
click(h, "field:shortcutsSearch");
|
||||
assert_eq!(h.request("ui.text", json!({"text": search}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
click(h, &format!("button:shortcut-{id}"));
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some(id));
|
||||
}
|
||||
|
||||
/// Pressing ⌘ (a key event of its own) before S used to record `Cmd+SuperLeft`, so no ⌘ shortcut
|
||||
/// could be set; ⌘C/⌘X/⌘V arrive as copy/cut/paste events, not keys, and were ignored.
|
||||
#[test]
|
||||
fn modifiers_wait_for_their_key_and_clipboard_keys_record() {
|
||||
let mut h = demo();
|
||||
record(&mut h, "survey", "view.survey");
|
||||
let r = h.request("ui.key", json!({"key": "SuperLeft", "cmd": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.survey"), "still waiting after ⌘ alone");
|
||||
let r = h.request("ui.key", json!({"key": "K", "cmd": true, "shift": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Shift+K"));
|
||||
// ⌘⌥C: the windowing layer sends a Copy event with the modifiers held
|
||||
record(&mut h, "survey", "view.survey");
|
||||
let m = egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND };
|
||||
h.app.synthetic.push(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
h.app.synthetic.push(egui::Event::Copy);
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Alt+C"));
|
||||
// a saved modifier-only shortcut (from the bug) means no shortcut
|
||||
assert_eq!(crate::shortcuts::parse("Cmd+SuperLeft"), None);
|
||||
}
|
||||
@@ -75,6 +75,8 @@ fn unopenable_library_asks_instead_of_running_a_demo() {
|
||||
assert!(!has(&h, "button:libraryRetry"));
|
||||
assert!(has(&h, "indicator:temporarySession"));
|
||||
assert!(h.app.session.library.is_none());
|
||||
// imported on the import worker (issue #374: never synchronously on the UI thread)
|
||||
h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1);
|
||||
assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session");
|
||||
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
|
||||
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T);
|
||||
|
||||
@@ -649,6 +649,20 @@ fn ai_masks_without_the_model_offer_the_download() {
|
||||
// no location configured in this build: no Download button (only Close), and an agent
|
||||
// confirming anyway gets the reason; the dialog stays
|
||||
h.step();
|
||||
// …but a way to install it by hand: the guide, and the model folder (created on demand)
|
||||
assert!(h.app.widgets.iter().any(|(id, _)| id == "link:samHelp"), "no installation guide link");
|
||||
let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let log = shown.clone();
|
||||
h.app.services.reveal = Some(Box::new(move |p: &str| {
|
||||
log.lock().unwrap().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
h.step();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:samFolder"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(dir.is_dir(), "the model folder is created to be shown");
|
||||
assert_eq!(*shown.lock().unwrap(), vec![dir.to_string_lossy().to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Check painted text as well as widget rectangles: custom painters can overflow a valid widget.
|
||||
|
||||
use egui::{Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LightcraftApp, Services, headless::HeadlessView, i18n::Locale};
|
||||
|
||||
const LONG_NAME: &str = "The person standing beside the very long fence across the background";
|
||||
|
||||
fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
egui::Shape::Text(text) => out.push(text.clone()),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel(width: f32, scale: f32, locale: Locale, describe: bool) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
app.run("mask.add", json!({"kind": "subject", "name": LONG_NAME})).unwrap();
|
||||
app.ui.describe = describe.then(|| ("new".into(), LONG_NAME.repeat(4)));
|
||||
app.ui.detail_due = Some((100.0, 1));
|
||||
let photo = app.session.active().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::i18n::set_language(locale);
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx);
|
||||
let mut texts = Vec::new();
|
||||
let mut widgets = Vec::new();
|
||||
for frame in 0..3 {
|
||||
let raw = HeadlessView::raw_input(vec2(width, 1600.0), scale, frame as f64 / 60.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
crate::panels::masking::show(&mut app, ui, photo);
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
texts.clear();
|
||||
for shape in &out.shapes {
|
||||
text_shapes(&shape.shape, &mut texts);
|
||||
}
|
||||
widgets = crate::widgets::take_registry(&ctx);
|
||||
}
|
||||
if describe {
|
||||
assert_eq!(app.ui.describe.as_ref().unwrap().1, LONG_NAME.repeat(4), "layout must not shorten the actual prompt");
|
||||
}
|
||||
crate::i18n::set_language(Locale::En);
|
||||
(texts, widgets)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_tile_labels_fit_without_eliding_at_supported_panel_widths() {
|
||||
for locale in [Locale::En, Locale::De, Locale::PtBr] {
|
||||
for width in [250.0, 270.0, 330.0, 520.0] {
|
||||
for scale in [1.0, 2.0] {
|
||||
let (texts, widgets) = panel(width, scale, locale, false);
|
||||
for (kind, label) in [("background", "Background"), ("luminanceRange", "Luminance"), ("prompt", "Describe")] {
|
||||
crate::i18n::set_language(locale);
|
||||
let label = crate::i18n::tr(label).to_string();
|
||||
crate::i18n::set_language(Locale::En);
|
||||
let tile = widgets.iter().find(|(id, _)| id == &format!("maskNew:{kind}")).unwrap().1;
|
||||
let text = texts.iter().find(|t| t.galley.job.text == label && t.pos.y < tile.bottom()).unwrap();
|
||||
assert!(!text.galley.elided, "{locale:?} {width} {label} should be readable in full");
|
||||
assert!(
|
||||
tile.shrink2(vec2(2.0, 0.0)).contains_rect(text.visual_bounding_rect()),
|
||||
"{locale:?} {width} {scale}: {label} exceeds {tile:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_long_names_and_describe_controls_stay_inside_the_panel() {
|
||||
for locale in [Locale::En, Locale::De, Locale::PtBr] {
|
||||
for width in [250.0, 270.0, 520.0] {
|
||||
let (texts, widgets) = panel(width, 2.0, locale, true);
|
||||
let row = widgets.iter().find(|(id, _)| id == "mask:1").unwrap().1;
|
||||
let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap();
|
||||
assert!(name.galley.elided, "long mask names should be elided");
|
||||
assert!(
|
||||
Rect::from_min_max(pos2(row.left() + 32.0, row.top()), pos2(row.right() - 28.0, row.bottom()))
|
||||
.contains_rect(name.visual_bounding_rect())
|
||||
);
|
||||
let field = widgets.iter().find(|(id, _)| id == "maskDescribe").unwrap().1;
|
||||
let button = widgets.iter().find(|(id, _)| id == "button:maskDescribeGo").unwrap().1;
|
||||
assert!(field.right() <= button.left(), "the prompt must not overlap Select");
|
||||
assert!(field.left() >= 24.0 && button.right() <= width - 22.0, "{locale:?} {width}: {field:?} {button:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,27 @@ fn masking_contents_fit_the_right_panel_at_any_width() {
|
||||
T,
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let long = "The person standing beside the very long fence across the background";
|
||||
let r = h.request("engine.execute", json!({"command": "mask.rename", "params": {"id": 1, "name": long}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for width in [RIGHT_WIDTH.min, RIGHT_WIDTH.default, 330.0, RIGHT_WIDTH.max] {
|
||||
// a long Describe prompt being typed: the field and its Select button stay in the panel
|
||||
h.app.ui.describe = Some(("new".into(), long.repeat(2)));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_inside_right_panel(&h, &format!("width {width}, describing"));
|
||||
let (field, go) = (widget(&h, "maskDescribe"), widget(&h, "button:maskDescribeGo"));
|
||||
assert!(field.right() <= go.left() && go.right() <= widget(&h, "panel:right_panel").right(), "width {width}: {field:?} {go:?}");
|
||||
h.app.ui.describe = None;
|
||||
// the painted text, not just the widgets: tile labels inside their tiles, a long mask
|
||||
// name cut short before the eye
|
||||
h.step();
|
||||
for (id, r) in h.app.widgets.iter().filter(|(id, _)| id.starts_with("maskNewLabel:")) {
|
||||
let tile = widget(&h, &id.replace("maskNewLabel:", "maskNew:"));
|
||||
assert!(tile.contains_rect(*r), "width {width}: {id} {r:?} outside its tile {tile:?}");
|
||||
}
|
||||
let (name, row) = (widget(&h, "maskName:1"), widget(&h, "mask:1"));
|
||||
assert!(name.right() <= row.right() - 27.0, "width {width}: the name {name:?} runs under the eye of {row:?}");
|
||||
for (selected, tool) in [(false, ""), (true, ""), (true, "brush")] {
|
||||
let mask = if selected { json!(2) } else { serde_json::Value::Null };
|
||||
h.request("engine.execute", json!({"command": "mask.select", "params": {"id": mask}}), T);
|
||||
@@ -377,7 +397,8 @@ fn a_renamed_folder_keeps_its_place_in_the_tree() {
|
||||
let _cleanup = Cleanup(base.clone());
|
||||
std::fs::create_dir_all(base.join("pics/trip")).unwrap();
|
||||
std::fs::create_dir_all(base.join("pics/home")).unwrap();
|
||||
let b = base.to_string_lossy().to_string();
|
||||
// the folder tree writes paths with forward slashes, and its widget ids carry them
|
||||
let b = base.to_string_lossy().replace('\\', "/");
|
||||
let mut h = folders_app(&[&format!("{b}/pics/trip/a.jpg"), &format!("{b}/pics/home/b.jpg")]);
|
||||
h.app.session.source = lightcraft_engine::LibrarySource::LibraryFolder;
|
||||
h.app.session.library_folder = Some(format!("{b}/pics/trip"));
|
||||
|
||||
@@ -593,6 +593,15 @@ mod access_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's
|
||||
/// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its
|
||||
/// buttons), which a plain `Painter::text` would draw past.
|
||||
pub(crate) fn one_line(painter: &egui::Painter, text: &str, font: egui::FontId, color: Color32, max: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(max.max(1.0));
|
||||
painter.layout_job(job)
|
||||
}
|
||||
|
||||
/// `text` shortened to fit `max` (as `width` measures it): leading folders drop first
|
||||
/// (`Users/me/Pictures/Lightroom` → `…/Pictures/Lightroom`) so the end of a path, which says the
|
||||
/// most, stays; a single name still too long loses its end (`2024-06-12 Tri…`: folders tend to
|
||||
|
||||
+3
-1
@@ -80,7 +80,9 @@ The model is fetched from an ordered list of mirrors: base URLs where `<base>/mo
|
||||
> **Maintainers:** the built-in list is **empty** until LightCraft's own CDN locations exist
|
||||
> (see the `TODO(maintainer)` there): add them in order of preference, host the three files
|
||||
> unchanged, and pin `vocab.json` / `merges.txt` (size + SHA-256) in `SAM3_FILES` at the same
|
||||
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so.
|
||||
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so:
|
||||
> it names the three files to put in the model folder by hand, with a button that shows that folder
|
||||
> (created if needed) and a link to this guide.
|
||||
|
||||
Hugging Face's `facebook/sam3` can't be a default: it is **gated** (each person must accept the
|
||||
licence there, wait for approval and download with their own token). Someone with access can
|
||||
|
||||
@@ -56,7 +56,7 @@ The additional supplied folder contains 748 RAFs. Profiles were fitted separatel
|
||||
|
||||
For validation, every fifth usable photo of each model in sorted path order was withheld. Profiles trained on the remaining 160 X-H2S and 436 X-T4 photos were tested on 41 and 109 withheld photos. Mean CIE76 ΔE against the camera JPEG was 9.86 → 1.70 with per-file fitting → 1.62 with the pooled X-H2S profile, and 13.64 → 2.43 → 2.34 for X-T4. The profiles improved 108 of 150 files; the largest regression against per-file fitting was 0.60 ΔE, with no previously accepted fit rejected. These are withheld files from the same limited scene collection, not independent coverage of all subjects or illuminants.
|
||||
|
||||
The three original dark X-H2S wrench files have too little colour variation to learn a matrix, but now use the known profile to fit tone: ΔE 8.74 → 2.25, all accepted. The four original X-T4 files and two public X-T4 files remain accepted with the final bundled profile; they regress slightly against their own per-file fits (six-file mean 4.72 → 4.94), while remaining much closer than the neutral fallback (13.47). One public X-T20 reference still fails the gates and keeps the fallback.
|
||||
The three original dark X-H2S wrench files have too little colour variation to learn a matrix, but now use the known profile to fit tone: ΔE 8.74 → 2.25, all accepted. The four original X-T4 files and two public X-T4 files remain accepted with the final bundled profile; they regress slightly against their own per-file fits (six-file mean 4.72 → 4.94), while remaining much closer than the neutral fallback (13.47). The public X-T20 reference fails the gates on all pixels (held-out RMS 0.114) and passes on the second attempt away from edges (0.056, from 0.185 uncorrected), so it is fitted too.
|
||||
|
||||
RAF inputs also work with `lightcraft-cli calibrate`. Tests cover real X-Trans/16-bit Bayer fits, profile use on dull scenes, per-file fitting after profile rejection, monochrome rejection, resolution independence, header/decoded relative WB, old-catalog WB transitions and invalid RAF calibration inputs. A full-size 6240×4160 X-T4 JPEG export also succeeded.
|
||||
|
||||
|
||||
+5
-2
@@ -22,8 +22,11 @@ CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers
|
||||
support lossless RAW and three-level C-RAW with horizontal tiles. Uniform
|
||||
quantization values 4–43 are independently verified. Version `0x200` C-RAW
|
||||
supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and
|
||||
its adaptive quantization map. Other unverified coding variants return explicit
|
||||
unsupported errors. Header probing validates the same coding variants and
|
||||
its adaptive quantization map. C-RAW is quantized, so next to clipped highlights
|
||||
its reconstruction can overshoot the sensor range by a few quantization steps
|
||||
(up to 36 codes above 16383 in 17 CC0 files); those samples are clamped to the
|
||||
CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data.
|
||||
Other unverified coding variants return explicit unsupported errors. Header probing validates the same coding variants and
|
||||
tile/plane layout as full decoding.
|
||||
|
||||
`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Lightroom Classic catalog import
|
||||
|
||||
File → Import Lightroom Catalog… opens `.lrcat` directly. Lightroom, Python, DNG conversion
|
||||
and a C SQLite runtime are not required. The source database is read-only, including committed
|
||||
`-wal` pages; a catalog that changes during the read is rejected. LightCraft owns subsequent edits.
|
||||
|
||||
Equivalent commands:
|
||||
|
||||
```text
|
||||
lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path="Lightroom Catalog.lrcat"
|
||||
lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat"
|
||||
```
|
||||
|
||||
Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata,
|
||||
hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop
|
||||
settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft
|
||||
edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import
|
||||
identity prevents reimport from duplicating virtual copies and collections. One undo step reverses
|
||||
catalog changes; any saved recovery archives remain available.
|
||||
|
||||
Before changing records, the importer attempts to save source image records, decoded XMP,
|
||||
verbatim develop settings, history, snapshots and collection content to compressed
|
||||
`Interop/lightroom-import-N.lca` recovery archives in the LightCraft library. Archiving is
|
||||
best-effort: a size limit or write failure produces a warning and does not prevent photo import.
|
||||
Unchanged source data reuses its existing archive. Opaque SQL BLOBs are skipped. Each archive is
|
||||
capped at 32 MiB (uncompressed and on disk), with at most eight archives and 128 MiB total
|
||||
retained. Oldest managed archives are retired; legacy `.json` archives remain untouched.
|
||||
`Interop/lightroom-index.json` records imported identities. Source catalogs and originals are
|
||||
never overwritten.
|
||||
|
||||
Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per
|
||||
table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format
|
||||
specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB.
|
||||
Optional history, keyword and collection tables that exceed limits are skipped with warnings;
|
||||
required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed
|
||||
WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional.
|
||||
|
||||
Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported
|
||||
mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI
|
||||
models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped
|
||||
fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots
|
||||
and original smart-collection rules. Smart collections become regular albums with current
|
||||
membership. Archived history and snapshots are source data, not native LightCraft history yet.
|
||||
Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings;
|
||||
it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone
|
||||
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
|
||||
|
||||
Native desktop inspection/import runs on a cancellable background worker with progress. Only
|
||||
prepared catalog operations commit on the owner thread; archive index writing stays on a worker.
|
||||
Late results from a closed or switched library are rejected, including reopening the same path.
|
||||
Ordinary edits during preparation remain intact. CLI commands complete synchronously using the
|
||||
same preparation/commit pipeline; browser builds reject native catalog import.
|
||||
|
||||
Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib-compressed JSON.
|
||||
Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths,
|
||||
settings, decoded XMP, keywords, history, snapshots, collection definitions and membership.
|
||||
|
||||
The importer uses shared Rust code on macOS, Windows and Linux. If originals move between
|
||||
computers or volumes, use LightCraft's missing-photo relinking tools to update their paths.
|
||||
@@ -0,0 +1,47 @@
|
||||
# LightCraft en español
|
||||
|
||||
Elegir **Editar → Idioma → Español** o **Ajustes → General → Idioma**. El cambio se aplica al
|
||||
momento y se conserva para el próximo inicio (`language: "es"` en `ui.json`). `LIGHTCRAFT_LANGUAGE`
|
||||
acepta cualquier variante del español (`es`, `es-ES`, `es_MX.UTF-8`, `es-419`…).
|
||||
|
||||
El catálogo en español cubre los menús, los métodos abreviados de teclado, la biblioteca, el
|
||||
revelado, el recorte, las máscaras, la importación y la exportación, los ajustes, los ajustes
|
||||
preestablecidos y perfiles integrados, el historial, las fechas y los mensajes de progreso. Como en
|
||||
los demás idiomas, los errores técnicos de los niveles inferiores siguen en inglés, y los textos
|
||||
que aún no están en el catálogo se muestran en inglés.
|
||||
|
||||
Los nombres de archivo, los identificadores de comandos, las plantillas y los álbumes, ajustes
|
||||
preestablecidos y metadatos con nombres propios no se traducen. La fuente de la interfaz, Inter,
|
||||
incluye todos los caracteres del español; no hacen falta fuentes adicionales.
|
||||
|
||||
## Terminología
|
||||
|
||||
Se usan infinitivos o formas impersonales en las instrucciones («Hacer clic…», «Elegir…»), sin
|
||||
tratamiento de usted. Algunos términos que conviene mantener coherentes:
|
||||
|
||||
| Inglés | Español |
|
||||
|---|---|
|
||||
| Highlights / Shadows | Iluminaciones / Sombras |
|
||||
| Dehaze | Eliminar neblina |
|
||||
| Vibrance | Intensidad |
|
||||
| Tint | Matiz |
|
||||
| Hue (HSL) | Tono |
|
||||
| Color Grading | Gradación de color |
|
||||
| Preset | Ajuste preestablecido |
|
||||
| Pick / Reject | Seleccionada / Rechazada |
|
||||
| Upright | Vertical |
|
||||
| Lens Corrections | Correcciones de lente |
|
||||
|
||||
Las correcciones de hablantes nativos son bienvenidas: abrir un issue o un pull request con el
|
||||
texto inglés, la traducción actual y la propuesta.
|
||||
|
||||
## Mantenimiento y comprobación
|
||||
|
||||
Textos fijos: `crates/ui-egui/locales/es.json`. Mensajes con valores:
|
||||
`crates/ui-egui/locales/es-formats.json`. Las claves en inglés no cambian; el compilador de Rust
|
||||
comprueba los marcadores al compilar. Las terminaciones de plural del inglés se ocultan con `{:.0}`.
|
||||
|
||||
```sh
|
||||
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
|
||||
LIGHTCRAFT_LANGUAGE=es lightcraft-cli snapshot --demo -o espanol.png --size 1600x1000
|
||||
```
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
The interface ships in the language it is written in (English) plus every language in the table in
|
||||
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
|
||||
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
|
||||
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md),
|
||||
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
|
||||
[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md).
|
||||
|
||||
## Adding a language
|
||||
|
||||
+14
-16
@@ -26,8 +26,8 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
|
||||
@@ -42,18 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
||||
| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
| Lightroom Classic extras | 32 | 10 | 39 | 9 | — | 22/23 (96%) |
|
||||
| **Total** | 390 | 34 | 86 | 37 | 194/200 (97%) | 140/150 (93%) |
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.7% of 150 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -127,7 +124,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) |
|
||||
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) |
|
||||
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
|
||||
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
|
||||
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) |
|
||||
@@ -138,7 +135,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
@@ -344,7 +341,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model |
|
||||
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic |
|
||||
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic |
|
||||
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand; see docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
|
||||
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
|
||||
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
|
||||
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
|
||||
@@ -484,7 +481,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
|
||||
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
|
||||
| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | |
|
||||
| LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | |
|
||||
| LR-PREF-DEVICE | Device settings | P2 | ⬜ | | |
|
||||
@@ -520,8 +517,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
@@ -731,6 +728,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here |
|
||||
|
||||
+21
-2
@@ -9,6 +9,11 @@
|
||||
- `Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.
|
||||
- Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).
|
||||
|
||||
### Keyboard shortcuts
|
||||
- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and
|
||||
press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that
|
||||
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
|
||||
|
||||
### RAW decoding
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
@@ -17,11 +22,23 @@
|
||||
are unchanged; photos imported before this change get it when imported again.
|
||||
- Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS
|
||||
M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.
|
||||
- Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS and Epson ERF files that LightCraft can't decode yet
|
||||
now import as "preview only" with their embedded JPEG instead of failing. A raw whose data is damaged but whose
|
||||
preview is intact does the same.
|
||||
- Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS
|
||||
TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode
|
||||
yet and import as "preview only", with the reason.
|
||||
- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,
|
||||
preserving RAW editing & full-resolution export instead of using embedded JPEG previews.
|
||||
- Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the
|
||||
starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.
|
||||
|
||||
### Lightroom Classic catalogs
|
||||
- File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.
|
||||
Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;
|
||||
existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported
|
||||
fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.
|
||||
|
||||
### Presets and profiles
|
||||
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
|
||||
bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.
|
||||
@@ -47,10 +64,12 @@
|
||||
(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.
|
||||
- The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks
|
||||
or unchecks a range of photos (issue #338).
|
||||
- When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):
|
||||
import only the raws and leave the JPEGs beside them (issue #344).
|
||||
|
||||
### Languages
|
||||
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
|
||||
Traditional), Japanese and Brazilian Portuguese.
|
||||
- The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,
|
||||
Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.
|
||||
|
||||
### Editing
|
||||
- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)
|
||||
|
||||
Reference in new issue
Block a user