mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 14:39:41 -06:00
Merge track r3-t08
# Conflicts: # crates/meta/README.md # crates/ui-egui/locales/cs.json # crates/ui-egui/locales/de.json # crates/ui-egui/locales/es.json # crates/ui-egui/locales/fi.json # crates/ui-egui/locales/fr.json # crates/ui-egui/locales/id-formats.json # crates/ui-egui/locales/id.json # crates/ui-egui/locales/it.json # crates/ui-egui/locales/ja.json # crates/ui-egui/locales/ka-formats.json # crates/ui-egui/locales/ka.json # crates/ui-egui/locales/pl.json # crates/ui-egui/locales/pt-br.json # crates/ui-egui/locales/ru.json # crates/ui-egui/locales/sv-formats.json # crates/ui-egui/locales/sv.json # crates/ui-egui/locales/uk.json # crates/ui-egui/locales/vi.json # crates/ui-egui/locales/zh-hans.json # crates/ui-egui/locales/zh-hant.json # crates/ui-egui/src/panels/grid.rs # crates/ui-egui/tests/it/main.rs # docs/gaps.md # docs/library-shortcuts.md # docs/parity-checklist.md # docs/raw-parity.md # docs/user/guides/export-photos.md # docs/whats-new.md
This commit is contained in:
@@ -118,6 +118,74 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncRow for Item {
|
||||
fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
|
||||
fn show_label(&mut self, label: String) {
|
||||
match &self.handle {
|
||||
Handle::Plain(h) => h.set_text(label.replace('&', "&&")),
|
||||
Handle::Check(h) => h.set_text(label.replace('&', "&&")),
|
||||
}
|
||||
self.label = label;
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn show_enabled(&mut self, enabled: bool) {
|
||||
match &self.handle {
|
||||
Handle::Plain(h) => h.set_enabled(enabled),
|
||||
Handle::Check(h) => h.set_enabled(enabled),
|
||||
}
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
fn show_checked(&mut self, checked: bool) {
|
||||
if let Handle::Check(h) = &self.handle {
|
||||
// also resyncs after AppKit toggled it on click
|
||||
if h.is_checked() != checked {
|
||||
h.set_checked(checked);
|
||||
}
|
||||
self.checked = Some(checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the per-frame sync needs of a native row, so it can be run on stand-ins without a
|
||||
/// main-thread `muda::Menu` (as [`SwapTarget`] does for the swap).
|
||||
trait SyncRow {
|
||||
fn label(&self) -> &str;
|
||||
fn show_label(&mut self, label: String);
|
||||
fn enabled(&self) -> bool;
|
||||
fn show_enabled(&mut self, enabled: bool);
|
||||
/// Only a row that is a check item takes it.
|
||||
fn show_checked(&mut self, checked: bool);
|
||||
}
|
||||
|
||||
/// Puts each model row's live label, enabled and checked state on its own native row, found by
|
||||
/// the row's native id (two rows may run the same command and params).
|
||||
fn sync_rows<R: SyncRow>(nodes: &[MenuNode], items: &mut HashMap<String, R>, keymap: &lightcraft_ui_egui::shortcuts::Keymap) {
|
||||
let mut rows = Vec::new();
|
||||
native_rows(nodes, &mut RowIds::default(), &mut rows);
|
||||
for (native_id, node) in rows {
|
||||
let MenuNode::Item { id, params, label, shortcut, enabled, checked } = node else { continue };
|
||||
let Some(it) = items.get_mut(&native_id) else { continue };
|
||||
let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref());
|
||||
if it.label() != native_label {
|
||||
it.show_label(native_label);
|
||||
}
|
||||
if it.enabled() != *enabled {
|
||||
it.show_enabled(*enabled);
|
||||
}
|
||||
if let Some(c) = checked {
|
||||
it.show_checked(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accelerator the native item has after trying to put `want` on it: `want` when the native
|
||||
/// call worked, else what it had, so the caller sees the mismatch and tries again.
|
||||
fn settled(had: Option<Accelerator>, want: Option<Accelerator>, native_call_worked: bool) -> Option<Accelerator> {
|
||||
@@ -311,6 +379,50 @@ fn tidy_separators(nodes: Vec<MenuNode>) -> Vec<MenuNode> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The model's rows of one menu as the native menu installs them: About, Settings and Quit live in
|
||||
/// the app menu on macOS.
|
||||
fn install_nodes(nodes: &[MenuNode]) -> Vec<MenuNode> {
|
||||
let kept = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS || id == QUIT));
|
||||
tidy_separators(kept.cloned().collect())
|
||||
}
|
||||
|
||||
/// Native (muda) ids, one per menu row. muda ids must be unique, but the model may hold the same
|
||||
/// command and params twice (`dialog.export` is File ▸ Export… and Export with Preset ▸ Custom…;
|
||||
/// `dialog.newAlbum` is in File and in Photo ▸ Add to Album), so a repeated key gets an occurrence
|
||||
/// suffix. The first row keeps the plain [`MenuNode::key`]. Rows are numbered in model order, so
|
||||
/// the install and the per-frame sync, which both walk the model in that order, agree.
|
||||
#[derive(Default)]
|
||||
struct RowIds {
|
||||
seen: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl RowIds {
|
||||
/// The row's native id, and whether an earlier row already has the same command and params.
|
||||
fn next(&mut self, id: &str, params: &Value) -> (String, bool) {
|
||||
let key = MenuNode::key(id, params);
|
||||
let n = self.seen.entry(key.clone()).or_insert(0);
|
||||
*n += 1;
|
||||
if *n == 1 { (key, false) } else { (format!("{key}#{n}"), true) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a row is installed with. A repeated row has none: AppKit would list one key twice, and
|
||||
/// the first row of the command keeps it (a click on either row runs the command).
|
||||
fn row_accelerator(native_key: Option<&str>, repeated: bool) -> Option<Accelerator> {
|
||||
native_key.filter(|s| !repeated && !CONTEXTUAL.contains(s)).and_then(accelerator)
|
||||
}
|
||||
|
||||
/// Every item of the model with its native id, in model order.
|
||||
fn native_rows<'a>(nodes: &'a [MenuNode], ids: &mut RowIds, out: &mut Vec<(String, &'a MenuNode)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Item { id, params, .. } => out.push((ids.next(id, params).0, n)),
|
||||
MenuNode::Submenu { children, .. } => native_rows(children, ids, out),
|
||||
MenuNode::Separator => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
/// Build the menu bar and install it as the application menu (main thread, after launch).
|
||||
pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu {
|
||||
@@ -379,6 +491,8 @@ impl NativeMenu {
|
||||
}
|
||||
let _ = self.menu.append(&app_menu);
|
||||
|
||||
let mut ids = RowIds::default();
|
||||
let mut help_menu = None;
|
||||
for (title, nodes) in &bar {
|
||||
let sub = Submenu::new(label_text(title), true);
|
||||
if title == "Window" {
|
||||
@@ -388,16 +502,10 @@ impl NativeMenu {
|
||||
&PredefinedMenuItem::separator(),
|
||||
]);
|
||||
}
|
||||
// About and Settings live in the app menu on macOS
|
||||
let nodes: Vec<MenuNode> = nodes
|
||||
.iter()
|
||||
.filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS || id == QUIT))
|
||||
.cloned()
|
||||
.collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
let nodes = install_nodes(nodes);
|
||||
// Swapping an item in the Help menu leaves the old row in place and adds a second one
|
||||
// (measured on macOS 27 with muda 0.21): the Help menu is not addressed row for row.
|
||||
self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help");
|
||||
self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help", &mut ids);
|
||||
if title == "File" {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[
|
||||
@@ -413,11 +521,16 @@ impl NativeMenu {
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
if title == "Help" {
|
||||
sub.set_as_help_menu_for_nsapp();
|
||||
help_menu = Some(sub.clone());
|
||||
}
|
||||
let _ = self.menu.append(&sub);
|
||||
}
|
||||
self.menu.init_for_nsapp();
|
||||
// muda finds the submenu through the main menu installed just above; asked earlier it does
|
||||
// nothing, and AppKit never learns which menu is the Help menu
|
||||
if let Some(help) = help_menu {
|
||||
help.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
self.text_focus = false;
|
||||
self.capturing = false;
|
||||
self.accelerators_pending = false;
|
||||
@@ -426,7 +539,7 @@ impl NativeMenu {
|
||||
|
||||
/// Install translated rows with their effective accelerators, preserving hint-only keys.
|
||||
/// `swappable`: whether its items may be replaced to drop their key (see `Item::swappable`).
|
||||
fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool) {
|
||||
fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool, ids: &mut RowIds) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Separator => {
|
||||
@@ -434,16 +547,13 @@ impl NativeMenu {
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
let s = Submenu::new(label_text(label), true);
|
||||
self.append_nodes(&s, children, keymap, swappable);
|
||||
self.append_nodes(&s, children, keymap, swappable, ids);
|
||||
let _ = sub.append(&s);
|
||||
}
|
||||
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
|
||||
let key = MenuNode::key(id, params);
|
||||
if self.items.contains_key(&key) {
|
||||
continue; // muda ids must be unique
|
||||
}
|
||||
let (key, repeated) = ids.next(id, params);
|
||||
let (native_label, native_key) = native_item_presentation(keymap, id, params, label, shortcut.as_deref());
|
||||
let accel = native_key.filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
|
||||
let accel = row_accelerator(native_key, repeated);
|
||||
let handle = match checked {
|
||||
Some(c) => {
|
||||
let it = CheckMenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, *c, accel);
|
||||
@@ -497,9 +607,7 @@ impl NativeMenu {
|
||||
// events never reach `shortcuts::handle`, so this is the one guard for AppKit's
|
||||
// accelerators and clicks: while the blocking library window or a quit/settings notice
|
||||
// owns input, a queued command is dropped (Quit stays for the existing confirmation).
|
||||
let (cmd, params) = if key == QUIT {
|
||||
(key, serde_json::json!({}))
|
||||
} else if key == "app.about" || key == SETTINGS {
|
||||
let (cmd, params) = if key == QUIT || key == "app.about" || key == SETTINGS {
|
||||
(key, serde_json::json!({}))
|
||||
} else if let Some(it) = self.items.get(&key) {
|
||||
(it.cmd.clone(), it.params.clone())
|
||||
@@ -516,41 +624,10 @@ impl NativeMenu {
|
||||
// to, so the sync below runs in this same frame and no key reaches the menu in between)
|
||||
self.rebuild(app);
|
||||
}
|
||||
/// Synchronize live labels and states; accelerator ownership changes already triggered a rebuild.
|
||||
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>, keymap: &lightcraft_ui_egui::shortcuts::Keymap) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Submenu { children, .. } => walk(children, items, keymap),
|
||||
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
|
||||
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
|
||||
let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref());
|
||||
if it.label != native_label {
|
||||
match &it.handle {
|
||||
Handle::Plain(h) => h.set_text(native_label.replace('&', "&&")),
|
||||
Handle::Check(h) => h.set_text(native_label.replace('&', "&&")),
|
||||
}
|
||||
it.label = native_label;
|
||||
}
|
||||
if it.enabled != *enabled {
|
||||
match &it.handle {
|
||||
Handle::Plain(h) => h.set_enabled(*enabled),
|
||||
Handle::Check(h) => h.set_enabled(*enabled),
|
||||
}
|
||||
it.enabled = *enabled;
|
||||
}
|
||||
if let (Handle::Check(h), Some(c)) = (&it.handle, checked) {
|
||||
// also resyncs after AppKit toggled it on click
|
||||
if h.is_checked() != *c {
|
||||
h.set_checked(*c);
|
||||
}
|
||||
it.checked = Some(*c);
|
||||
}
|
||||
}
|
||||
MenuNode::Separator => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items, &app.ui.settings.keymap);
|
||||
// Synchronize live labels and states row by row (accelerator ownership changes already
|
||||
// triggered a rebuild).
|
||||
let all: Vec<MenuNode> = bar.into_iter().flat_map(|(_, v)| v).collect();
|
||||
sync_rows(&all, &mut self.items, &app.ui.settings.keymap);
|
||||
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
let capturing = app.recording_shortcut.is_some();
|
||||
@@ -613,6 +690,134 @@ mod tests {
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
/// Every row of the model gets its own native row and keeps its own label, and a click on a
|
||||
/// native id runs that row's command (two rows may share a command and params).
|
||||
#[test]
|
||||
fn rows_sharing_a_command_get_their_own_native_ids_and_labels() {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
let bar = menu_bar(&app);
|
||||
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
|
||||
let mut rows = Vec::new();
|
||||
native_rows(&all, &mut RowIds::default(), &mut rows);
|
||||
fn count(n: &[MenuNode]) -> usize {
|
||||
n.iter()
|
||||
.map(|x| match x {
|
||||
MenuNode::Item { .. } => 1,
|
||||
MenuNode::Submenu { children, .. } => count(children),
|
||||
MenuNode::Separator => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
assert_eq!(rows.len(), count(&all));
|
||||
let ids: HashSet<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids.len(), rows.len(), "a native id per row");
|
||||
// The install walks each menu as `install_nodes` leaves it, the per-frame sync walks the whole
|
||||
// bar: a row the menu has must get the same id, so the same node (label, command, params),
|
||||
// from both, and a click on its id runs that row's command.
|
||||
let mut installed = Vec::new();
|
||||
let mut install_ids = RowIds::default();
|
||||
let menus: Vec<Vec<MenuNode>> = bar.iter().map(|(_, nodes)| install_nodes(nodes)).collect();
|
||||
for nodes in &menus {
|
||||
native_rows(nodes, &mut install_ids, &mut installed);
|
||||
}
|
||||
let synced: HashMap<&str, &MenuNode> = rows.iter().map(|(id, n)| (id.as_str(), *n)).collect();
|
||||
for (nid, n) in &installed {
|
||||
assert_eq!(synced.get(nid.as_str()), Some(n), "{nid}");
|
||||
}
|
||||
for (cmd, labels) in [("dialog.export", ["Export…", "Custom…"]), ("dialog.newAlbum", ["New Album…", "New Album…"])] {
|
||||
let own: Vec<(&str, &str)> = installed
|
||||
.iter()
|
||||
.filter_map(|(nid, n)| match n {
|
||||
MenuNode::Item { id, label, params, .. } if id == cmd && params.is_null() => Some((nid.as_str(), label.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(own.len(), 2, "{cmd} is in two menus");
|
||||
assert_ne!(own[0].0, own[1].0, "{cmd}: one native id per row");
|
||||
assert_eq!([own[0].1, own[1].1], labels, "{cmd}: each row keeps its label");
|
||||
}
|
||||
// A repeated row is installed without its key (AppKit would list one key twice).
|
||||
assert_eq!(row_accelerator(Some("G"), false), accelerator("G"));
|
||||
assert_eq!(row_accelerator(Some("G"), true), None);
|
||||
let mut ids = RowIds::default();
|
||||
assert_eq!(ids.next("view.layoutImage", &Value::Null), ("view.layoutImage".to_string(), false));
|
||||
assert_eq!(ids.next("view.layoutImage", &Value::Null), ("view.layoutImage#2".to_string(), true));
|
||||
}
|
||||
|
||||
/// A native row's shown state, to run the per-frame sync without a main-thread `muda::Menu`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct FakeRow {
|
||||
label: String,
|
||||
enabled: bool,
|
||||
checked: Option<bool>,
|
||||
}
|
||||
impl SyncRow for FakeRow {
|
||||
fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
fn show_label(&mut self, label: String) {
|
||||
self.label = label;
|
||||
}
|
||||
fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
fn show_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
fn show_checked(&mut self, checked: bool) {
|
||||
self.checked = Some(checked);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two rows with the same command and params keep their own label, enabled and checked state
|
||||
/// when the model changes (the sync once wrote the last row's state onto the first).
|
||||
#[test]
|
||||
fn the_sync_updates_rows_sharing_a_command_independently() {
|
||||
use lightcraft_ui_egui::i18n::{Locale, set_language};
|
||||
set_language(Locale::En);
|
||||
let keymap = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()).ui.settings.keymap.clone();
|
||||
let item = |cmd: &str, label: &str, enabled: bool, checked: Option<bool>| MenuNode::Item {
|
||||
id: cmd.into(),
|
||||
params: Value::Null,
|
||||
label: label.into(),
|
||||
shortcut: None,
|
||||
enabled,
|
||||
checked,
|
||||
};
|
||||
let model = |a: (&str, bool, Option<bool>), b: (&str, bool, Option<bool>), c: (&str, bool, Option<bool>), d: (&str, bool, Option<bool>)| {
|
||||
vec![
|
||||
item("dialog.export", a.0, a.1, a.2),
|
||||
MenuNode::Separator,
|
||||
MenuNode::Submenu { label: "Export with Preset".into(), children: vec![item("dialog.export", b.0, b.1, b.2)] },
|
||||
item("view.toggle", c.0, c.1, c.2),
|
||||
MenuNode::Submenu { label: "More".into(), children: vec![item("view.toggle", d.0, d.1, d.2)] },
|
||||
]
|
||||
};
|
||||
// what the native rows show after the install
|
||||
let installed = model(("Export…", true, None), ("Custom…", true, None), ("Show", true, Some(false)), ("Show", true, Some(false)));
|
||||
let mut rows = Vec::new();
|
||||
native_rows(&installed, &mut RowIds::default(), &mut rows);
|
||||
let mut items: HashMap<String, FakeRow> = rows
|
||||
.iter()
|
||||
.filter_map(|(nid, n)| match n {
|
||||
MenuNode::Item { label, enabled, checked, .. } => {
|
||||
Some((nid.clone(), FakeRow { label: label.clone(), enabled: *enabled, checked: *checked }))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(items.len(), 4, "a native row for each of the four rows");
|
||||
// the model changes: each row of a pair gets a different state
|
||||
let live = model(("Export…", false, None), ("Custom…", true, None), ("Show", true, Some(true)), ("Hide", false, Some(false)));
|
||||
sync_rows(&live, &mut items, &keymap);
|
||||
let shown: Vec<&FakeRow> = rows.iter().map(|(nid, _)| &items[nid]).collect();
|
||||
let row = |label: &str, enabled: bool, checked: Option<bool>| FakeRow { label: label.into(), enabled, checked };
|
||||
assert_eq!(shown[0], &row("Export…", false, None), "File ▸ Export… keeps its label and is disabled alone");
|
||||
assert_eq!(shown[1], &row("Custom…", true, None), "Custom… stays enabled and keeps its label");
|
||||
assert_eq!(shown[2], &row("Show", true, Some(true)), "the first toggle is checked alone");
|
||||
assert_eq!(shown[3], &row("Hide", false, Some(false)), "the second toggle has its own label and state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accelerators_yield_to_text_focus_and_shortcut_recording() {
|
||||
let g = accelerator("G");
|
||||
|
||||
@@ -46,7 +46,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
|
||||
| Format | Decode | Encode | Notes |
|
||||
|---|---|---|---|
|
||||
| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2, multi-chunk), Adobe APP14, MPF previews |
|
||||
| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2; only complete, consistently counted, uniquely numbered chunk sequences are assembled; malformed profiles use the existing unsupported-profile fallback), Adobe APP14, MPF previews |
|
||||
| PNG | yes, 1–16-bit, palette, tRNS, interlaced | 8/16-bit, gray/GA/RGB/RGBA | iCCP, eXIf, iTXt XMP; sRGB/cICP/cHRM/gAMA honoured when no ICC |
|
||||
| TIFF | 8/16-bit int (32/64-bit int kept to 16 bits), 16/32/64-bit float, gray/RGB/CMYK/palette, alpha (assoc./unassoc.), planar, LZW/Deflate/PackBits/JPEG | 8/16-bit, 32-bit float; none/LZW/Deflate/PackBits | ICC (34675), XMP (700), Orientation; first IFD only; EXIF not written (use `lightcraft-tiff`) |
|
||||
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
|
||||
|
||||
@@ -78,6 +78,8 @@ pub(crate) struct Raw {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Meta {
|
||||
pub icc: Option<Vec<u8>>,
|
||||
/// An ICC profile was declared by the container but its sequence was invalid.
|
||||
pub icc_invalid: bool,
|
||||
pub exif: Option<Vec<u8>>,
|
||||
pub xmp: Option<String>,
|
||||
/// Orientation from the container itself (e.g. TIFF tag); EXIF blob is consulted otherwise.
|
||||
@@ -120,7 +122,7 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
|
||||
let (image, space) = if let Some(space) = meta.already_linear.clone() {
|
||||
(to_rgb_passthrough(&raw, ch), space)
|
||||
} else if raw.model == Model::Cmyk {
|
||||
cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref())
|
||||
cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref(), meta.icc_invalid)
|
||||
} else {
|
||||
rgb_or_gray(&raw, ch, &meta, parsed_icc.as_ref(), float)
|
||||
};
|
||||
@@ -299,7 +301,7 @@ fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, f
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if meta.icc.is_some() {
|
||||
if meta.icc.is_some() || meta.icc_invalid {
|
||||
// Present but unusable (malformed, mismatched model, CMS failure): assume sRGB.
|
||||
let s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccUnsupported);
|
||||
let t = NamedSpace::Srgb.trc();
|
||||
@@ -323,7 +325,7 @@ fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, f
|
||||
(linearize(raw, ch, &t), s)
|
||||
}
|
||||
|
||||
fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>) -> (Rgb32f, SourceSpace) {
|
||||
fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>, icc_invalid: bool) -> (Rgb32f, SourceSpace) {
|
||||
let samples = device_samples(raw, ch, 4);
|
||||
if let (Some(bytes), Some(info)) = (icc_bytes, info)
|
||||
&& info.model == IccColorModel::Cmyk
|
||||
@@ -343,6 +345,6 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
|
||||
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
|
||||
})
|
||||
.collect();
|
||||
let origin = if icc_bytes.is_some() { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive };
|
||||
let origin = if icc_bytes.is_some() || icc_invalid { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive };
|
||||
(from_vec(raw.width, raw.height, px), SourceSpace::named(NamedSpace::Srgb, origin))
|
||||
}
|
||||
|
||||
+166
-12
@@ -24,6 +24,8 @@ pub(crate) struct Markers {
|
||||
pub exif_offset: usize,
|
||||
pub xmp: Option<String>,
|
||||
pub icc: Option<Vec<u8>>,
|
||||
/// An ICC_PROFILE APP2 segment was present but its sequence was invalid.
|
||||
pub icc_invalid: bool,
|
||||
pub adobe_transform: Option<u8>,
|
||||
/// Offset of the MPF TIFF header within the file and its bytes.
|
||||
pub mpf: Option<(usize, Vec<u8>)>,
|
||||
@@ -45,7 +47,8 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
return None;
|
||||
}
|
||||
let mut m = Markers::default();
|
||||
let mut icc_chunks: Vec<(u8, &[u8])> = Vec::new();
|
||||
let mut icc_chunks: [Option<&[u8]>; 255] = [None; 255];
|
||||
let mut icc_total = None;
|
||||
let mut p = 2;
|
||||
while p + 4 <= b.len() {
|
||||
if b[p] != 0xFF {
|
||||
@@ -73,9 +76,11 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
if len < 2 {
|
||||
return None;
|
||||
}
|
||||
let start = p + 4;
|
||||
let end = (p + 2 + len).min(b.len());
|
||||
let seg = &b[start..end];
|
||||
let start = p.checked_add(4)?;
|
||||
let declared_end = p.checked_add(2)?.checked_add(len)?;
|
||||
let complete = declared_end <= b.len();
|
||||
let end = declared_end.min(b.len());
|
||||
let seg = b.get(start..end)?;
|
||||
match marker {
|
||||
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => {
|
||||
m.precision = seg[0];
|
||||
@@ -96,8 +101,30 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
}
|
||||
}
|
||||
0xE2 => {
|
||||
if seg.starts_with(b"ICC_PROFILE\0") && seg.len() >= 14 {
|
||||
icc_chunks.push((seg[12], &seg[14..]));
|
||||
if seg.starts_with(b"ICC_PROFILE\0") {
|
||||
if !complete || seg.len() < 14 {
|
||||
m.icc_invalid = true;
|
||||
} else if let (Some(&sequence), Some(&total)) = (seg.get(12), seg.get(13)) {
|
||||
if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) {
|
||||
m.icc_invalid = true;
|
||||
} else {
|
||||
icc_total = Some(total);
|
||||
let Some(slot) = icc_chunks.get_mut(usize::from(sequence - 1)) else {
|
||||
m.icc_invalid = true;
|
||||
p = declared_end;
|
||||
continue;
|
||||
};
|
||||
if slot.is_some() {
|
||||
m.icc_invalid = true;
|
||||
} else if let Some(data) = seg.get(14..) {
|
||||
*slot = Some(data);
|
||||
} else {
|
||||
m.icc_invalid = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.icc_invalid = true;
|
||||
}
|
||||
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
|
||||
m.mpf = Some((start + 4, seg[4..].to_vec()));
|
||||
} else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
|
||||
@@ -111,11 +138,32 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p += 2 + len;
|
||||
p = declared_end;
|
||||
}
|
||||
if !icc_chunks.is_empty() {
|
||||
icc_chunks.sort_by_key(|c| c.0);
|
||||
m.icc = Some(icc_chunks.iter().flat_map(|c| c.1.iter().copied()).collect());
|
||||
if let Some(total) = icc_total {
|
||||
let mut profile_len = 0usize;
|
||||
for chunk in icc_chunks.iter().take(usize::from(total)) {
|
||||
let Some(bytes) = chunk else {
|
||||
m.icc_invalid = true;
|
||||
break;
|
||||
};
|
||||
let Some(next_len) = profile_len.checked_add(bytes.len()) else {
|
||||
m.icc_invalid = true;
|
||||
break;
|
||||
};
|
||||
profile_len = next_len;
|
||||
}
|
||||
let maximum_len = usize::from(total).checked_mul(65_519);
|
||||
if maximum_len.is_none_or(|maximum| profile_len > maximum) {
|
||||
m.icc_invalid = true;
|
||||
}
|
||||
if !m.icc_invalid {
|
||||
let mut profile = Vec::with_capacity(profile_len);
|
||||
for bytes in icc_chunks.iter().take(usize::from(total)).flatten() {
|
||||
profile.extend_from_slice(bytes);
|
||||
}
|
||||
m.icc = Some(profile);
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
@@ -257,8 +305,10 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
|
||||
};
|
||||
// A raw container may carry the colour declaration of an otherwise untagged preview.
|
||||
// Apply it before linearization/resizing; existing JPEG metadata always wins.
|
||||
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
|
||||
let hint = fallback
|
||||
.filter(|_| m.icc.is_none() && !m.icc_invalid && m.exif.is_none())
|
||||
.map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
|
||||
let meta = Meta { icc: m.icc, icc_invalid: m.icc_invalid, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
|
||||
finish(F, raw, meta, (m.width, m.height), opts)
|
||||
}
|
||||
|
||||
@@ -506,6 +556,110 @@ mod tests {
|
||||
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
|
||||
}
|
||||
|
||||
fn app2_icc(sequence: u8, total: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut payload = b"ICC_PROFILE\0".to_vec();
|
||||
payload.extend_from_slice(&[sequence, total]);
|
||||
payload.extend_from_slice(data);
|
||||
let mut segment = vec![0xff, 0xe2];
|
||||
segment.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
|
||||
segment.extend_from_slice(&payload);
|
||||
segment
|
||||
}
|
||||
|
||||
fn jpeg_with_icc_chunks(chunks: &[Vec<u8>]) -> Vec<u8> {
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
let pixels = [180, 60, 40, 180, 60, 40, 180, 60, 40, 180, 60, 40];
|
||||
let base = encode_jpeg(&EncodeImage::new(2, 2, 3, Samples::U8(&pixels)), 100, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let mut jpeg = base.get(..2).unwrap_or_default().to_vec();
|
||||
for chunk in chunks {
|
||||
jpeg.extend_from_slice(chunk);
|
||||
}
|
||||
jpeg.extend_from_slice(base.get(2..).unwrap_or_default());
|
||||
jpeg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_accepts_reordered_chunks_and_preserves_pixels() {
|
||||
use crate::{NamedSpace, SpaceOrigin, icc};
|
||||
let profile = icc::write_named(NamedSpace::DisplayP3);
|
||||
let (first, second) = profile.split_at(profile.len() / 2);
|
||||
let single = jpeg_with_icc_chunks(&[app2_icc(1, 1, &profile)]);
|
||||
let reordered = jpeg_with_icc_chunks(&[app2_icc(2, 2, second), app2_icc(1, 2, first)]);
|
||||
|
||||
let one = crate::decode(&single, DecodeOptions::default()).unwrap();
|
||||
let two = crate::decode(&reordered, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(one.icc.as_deref(), Some(profile.as_slice()));
|
||||
assert_eq!(two.icc.as_deref(), Some(profile.as_slice()));
|
||||
assert!(!parse_markers(&reordered).unwrap().icc_invalid);
|
||||
assert_eq!(one.space, two.space);
|
||||
assert_eq!(two.space.origin, SpaceOrigin::IccMatrixTrc);
|
||||
let (a, b) = (one.to_working(), two.to_working());
|
||||
assert_eq!(a.data.len(), b.data.len());
|
||||
for (left, right) in a.data.iter().zip(&b.data) {
|
||||
assert!(left.iter().zip(right).all(|(x, y)| (x - y).abs() < 0.00001), "{left:?} != {right:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_rejects_incoherent_chunks_and_uses_unsupported_fallback() {
|
||||
use crate::{NamedSpace, SpaceOrigin, icc};
|
||||
let profile = icc::write_named(NamedSpace::DisplayP3);
|
||||
let (first, second) = profile.split_at(profile.len() / 2);
|
||||
let base = jpeg_with_icc_chunks(&[]);
|
||||
let reference = crate::decode(&base, DecodeOptions::default()).unwrap();
|
||||
let cases = [
|
||||
("missing", vec![app2_icc(1, 2, &profile)]),
|
||||
("duplicate", vec![app2_icc(1, 2, first), app2_icc(1, 2, second)]),
|
||||
("zero sequence", vec![app2_icc(0, 1, &profile)]),
|
||||
("zero total", vec![app2_icc(1, 0, &profile)]),
|
||||
("out of range", vec![app2_icc(2, 1, &profile)]),
|
||||
("gap", vec![app2_icc(1, 3, first), app2_icc(3, 3, second)]),
|
||||
("inconsistent totals", vec![app2_icc(1, 2, first), app2_icc(2, 3, second)]),
|
||||
("short header", {
|
||||
let mut segment = vec![0xff, 0xe2];
|
||||
segment.extend_from_slice(&15u16.to_be_bytes());
|
||||
segment.extend_from_slice(b"ICC_PROFILE\0\x01");
|
||||
vec![segment]
|
||||
}),
|
||||
];
|
||||
for (name, chunks) in cases {
|
||||
let jpeg = jpeg_with_icc_chunks(&chunks);
|
||||
let markers = parse_markers(&jpeg).unwrap();
|
||||
let decoded = crate::decode(&jpeg, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported, "{name}: invalid profile must not be applied");
|
||||
assert!(markers.icc.is_none(), "{name}: invalid ICC must not be assembled");
|
||||
assert!(markers.icc_invalid, "{name}: invalid ICC presence must be retained");
|
||||
assert_eq!(decoded.icc, None, "{name}: invalid ICC bytes must not escape decode");
|
||||
assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported, "{name}");
|
||||
assert_eq!(decoded.space.named, Some(NamedSpace::Srgb), "{name}");
|
||||
assert_eq!(decoded.to_working(), reference.to_working(), "{name}: fallback pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_invalid_sequence_prevents_raw_colour_hint() {
|
||||
use crate::{NamedSpace, SpaceOrigin, icc};
|
||||
let profile = icc::write_named(NamedSpace::DisplayP3);
|
||||
let jpeg = jpeg_with_icc_chunks(&[app2_icc(1, 2, &profile)]);
|
||||
let decoded = crate::decode_jpeg_with_fallback(&jpeg, DecodeOptions::default(), NamedSpace::AdobeRgb).unwrap();
|
||||
assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported);
|
||||
assert_eq!(decoded.space.named, Some(NamedSpace::Srgb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_rejects_truncated_app2_segment() {
|
||||
use crate::{NamedSpace, icc};
|
||||
let profile = icc::write_named(NamedSpace::DisplayP3);
|
||||
let mut segment = app2_icc(1, 1, &profile);
|
||||
let declared = u16::from_be_bytes([segment[2], segment[3]]).saturating_add(1);
|
||||
segment[2..4].copy_from_slice(&declared.to_be_bytes());
|
||||
let mut jpeg = vec![0xff, 0xd8];
|
||||
jpeg.extend_from_slice(&segment);
|
||||
let markers = parse_markers(&jpeg).unwrap();
|
||||
assert!(markers.icc.is_none());
|
||||
assert!(markers.icc_invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_request_covers() {
|
||||
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
|
||||
|
||||
@@ -79,7 +79,12 @@ use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights};
|
||||
/// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a
|
||||
/// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data
|
||||
/// stored with each smart preview.
|
||||
pub const LOOK_VERSION: u32 = 12;
|
||||
/// 13: tone curves no steeper than a log-log slope of 4 between knots (#1904).
|
||||
/// 13: the Canon CR3/CR2 camera JPEG read in the colour space of the Canon maker note (#2654).
|
||||
/// 13: the sensor proxy of every per-file fit gets the embedded vignetting correction the render applies (Fujifilm
|
||||
/// RAF; other makes with a lens database), so the render no longer applies it twice, at the position a lens warp samples;
|
||||
/// the warped candidate's alignment leaves out the pixels outside the sensor instead of reading them as black.
|
||||
pub const LOOK_VERSION: u32 = 13;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::look_fit::{fit_dng_tone, fit_preview};
|
||||
use super::look_fit::{fit_dng_tone, fit_preview, look_proxies};
|
||||
use super::mono::{fit_dark_tone, monochrome_look};
|
||||
use super::proxy::{LensGeometry, proxies};
|
||||
use super::proxy::LensGeometry;
|
||||
use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford};
|
||||
|
||||
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
|
||||
@@ -42,7 +42,8 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
/// as-shot white, the camera transform at it and the fitted look.
|
||||
/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
|
||||
/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing.
|
||||
/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit.
|
||||
/// `embedded` is the file's embedded lens correction: its vignetting is applied to the sensor proxy of every fit, as
|
||||
/// the render applies it.
|
||||
pub(crate) fn starting_colour(
|
||||
raw: &mut RawImage,
|
||||
bytes: &[u8],
|
||||
@@ -51,13 +52,13 @@ pub(crate) fn starting_colour(
|
||||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
let (xy, t, look) = starting_colour_with(
|
||||
raw,
|
||||
|raw, t| fit_preview(raw, bytes, t, lens),
|
||||
|raw, t| monochrome_look(raw, bytes, t, lens),
|
||||
|raw, t| fit_preview(raw, bytes, t, lens, embedded),
|
||||
|raw, t| monochrome_look(raw, bytes, t, lens, embedded),
|
||||
|raw, t| {
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?;
|
||||
let (sensor, reference, clipped) = look_proxies(raw, bytes, t, lens, embedded)?;
|
||||
fit_dark_tone(&sensor, &reference, &clipped)
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,11 +6,18 @@ use super::tone::{HighlightCheck, highlights};
|
||||
use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020};
|
||||
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) {
|
||||
/// `embedded`: the file's embedded lens correction, whose vignetting the render applies ([`look_proxies`]).
|
||||
pub(crate) fn fit_preview(
|
||||
raw: &RawImage,
|
||||
bytes: &[u8],
|
||||
transform: &CameraTransform,
|
||||
lens: Option<&LensGeometry>,
|
||||
embedded: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
) -> (Option<CameraLook>, bool) {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
return (None, false);
|
||||
}
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) };
|
||||
let Some((sensor, reference, clipped)) = look_proxies(raw, bytes, transform, lens, embedded) else { return (None, false) };
|
||||
let black_and_white = monochrome(&reference);
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||||
@@ -207,7 +214,9 @@ pub(super) fn fit_dng_tone(
|
||||
}
|
||||
None
|
||||
};
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") };
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None, None) else {
|
||||
return refuse("preview");
|
||||
};
|
||||
// As the loader renders it: the profile's tables around the baseline exposure.
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||||
if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) {
|
||||
@@ -257,6 +266,20 @@ pub(super) fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap,
|
||||
near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE
|
||||
}
|
||||
|
||||
/// The proxies a photo's look is fitted on ([`proxies`] at [`PROXY`]), the sensor's with the embedded vignetting
|
||||
/// correction the render applies. Without it the tone curve learns the camera JPEG's corrected
|
||||
/// brightness from the uncorrected sensor, and the render then corrects it again: 21 X-S10 RAFs rendered up to 0.3 EV
|
||||
/// brighter in their midtones and 0.6 EV in their shadows than their camera JPEGs, about 0.05 EV once corrected.
|
||||
pub(super) fn look_proxies(
|
||||
raw: &RawImage,
|
||||
bytes: &[u8],
|
||||
transform: &CameraTransform,
|
||||
lens: Option<&LensGeometry>,
|
||||
embedded: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
proxies(raw, bytes, transform, PROXY, 0.0, lens, embedded)
|
||||
}
|
||||
|
||||
/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the
|
||||
/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the
|
||||
/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::colour::{PROXY, file_local_look};
|
||||
use super::colour::file_local_look;
|
||||
use super::hue_sat::HueSat;
|
||||
use super::look_fit::{with_dro_off_tone, without_clipped};
|
||||
use super::look_fit::{look_proxies, with_dro_off_tone, without_clipped};
|
||||
use super::pairs::{
|
||||
EDGE_CONTRAST, MIN_IMPROVEMENT, PARTIAL_MIN_RANK_CORRELATION, Pairs, collect_pairs, displayed, fit_tone_with_shadows, local_contrast, luma,
|
||||
rank_correlation_ties,
|
||||
};
|
||||
use super::proxy::{LensGeometry, proxies};
|
||||
use super::proxy::LensGeometry;
|
||||
use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold};
|
||||
use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020};
|
||||
|
||||
@@ -33,12 +33,18 @@ pub(super) fn monochrome(reference: &Rgb32f) -> bool {
|
||||
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
|
||||
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
|
||||
/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The
|
||||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
|
||||
pub(super) fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
|
||||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn. `embedded` as for [`fit_preview`].
|
||||
pub(super) fn monochrome_look(
|
||||
raw: &RawImage,
|
||||
bytes: &[u8],
|
||||
transform: &CameraTransform,
|
||||
lens: Option<&LensGeometry>,
|
||||
embedded: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
) -> Option<CameraLook> {
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
|
||||
let (sensor, reference, clipped) = look_proxies(raw, bytes, transform, lens, embedded)?;
|
||||
if !monochrome(&reference) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::colour::{PROFILE_PROXY, PROXY, file_local_look};
|
||||
use super::hue_sat::fit_hue_sat;
|
||||
use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped};
|
||||
use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, correct_vignetting, without_clipped};
|
||||
use super::pairs::{collect_pairs, fit_matrix};
|
||||
use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize};
|
||||
|
||||
@@ -82,7 +82,8 @@ pub(super) fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool {
|
||||
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
|
||||
/// and per proxy pixel whether any sensor sample it covers is clipped.
|
||||
/// and per proxy pixel whether any sensor sample it covers is clipped. `embedded`'s vignetting correction is applied to
|
||||
/// the sensor ([`correct_vignetting`]) before any `lens` warp, at the source position, as the render applies it.
|
||||
pub(super) fn proxies(
|
||||
raw: &RawImage,
|
||||
bytes: &[u8],
|
||||
@@ -90,6 +91,7 @@ pub(super) fn proxies(
|
||||
size: usize,
|
||||
min_fraction: f64,
|
||||
lens: Option<&LensGeometry>,
|
||||
embedded: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
@@ -125,6 +127,7 @@ pub(super) fn proxies(
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut clipped = clip_mask(&sensor);
|
||||
correct_vignetting(&mut sensor, embedded, raw.orientation);
|
||||
// The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 %
|
||||
// zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate,
|
||||
// kept below when it lines up with the JPEG better than the uncorrected one.
|
||||
@@ -319,7 +322,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeom
|
||||
return None;
|
||||
}
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens, None)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
// clipped sensor pixels don't record the scene's colour (see `fit_look`)
|
||||
let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor);
|
||||
@@ -369,7 +372,10 @@ pub(super) fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 {
|
||||
if (b.width, b.height) != (w, h) || w < 3 || h < 3 {
|
||||
return -1.0;
|
||||
}
|
||||
let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::<Vec<f64>>();
|
||||
// NaN (outside a lens-warped proxy) stays NaN: `max` would turn it into a black border and a false edge
|
||||
let lum = |img: &Rgb32f| {
|
||||
img.data.iter().map(|p| luminance_2020(*p)).map(|y| if y.is_nan() { f64::NAN } else { (y.max(1e-4) as f64).ln() }).collect::<Vec<f64>>()
|
||||
};
|
||||
let high = |l: &[f64]| -> Vec<f64> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::tone::{ELASTICITY_BLACK, MAX_TONE_ELASTICITY, TONE_REPAIR_ELASTICITY, limit_elasticity};
|
||||
use super::*;
|
||||
|
||||
fn steep_toe_scene() -> (Rgb32f, Rgb32f) {
|
||||
@@ -26,6 +27,74 @@ fn camera_tone_follows_the_jpegs_deep_shadows() {
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
}
|
||||
|
||||
/// Issue #1904: two populations the camera renders at different brightness for the same scene luminance (saturated
|
||||
/// blue at 1.7x, red at 2.64x) made quantile matching join them with a near-vertical step.
|
||||
#[test]
|
||||
fn tone_fit_does_not_step_between_two_pixel_populations() {
|
||||
let mut pairs = Vec::new();
|
||||
for i in 0..620 {
|
||||
let x = 0.040 + 0.012 * f64::from(i) / 620.0;
|
||||
pairs.push((x, 1.7 * x));
|
||||
}
|
||||
for i in 0..380 {
|
||||
let x = 0.02 * (10.0f64).powf(f64::from(i) / 380.0);
|
||||
pairs.push((x, 2.64 * x));
|
||||
}
|
||||
let elasticity = |k: &[[f32; 2]; 32]| {
|
||||
k.windows(2)
|
||||
.map(|w| {
|
||||
((f64::from(w[1][1]) + ELASTICITY_BLACK) / (f64::from(w[0][1]) + ELASTICITY_BLACK)).ln()
|
||||
/ (f64::from(w[1][0]) / f64::from(w[0][0])).ln()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
let mut xs: Vec<f64> = pairs.iter().map(|p| p.0).collect();
|
||||
let mut ys: Vec<f64> = pairs.iter().map(|p| p.1).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
ys.sort_by(f64::total_cmp);
|
||||
let raw: [[f32; 2]; 32] = std::array::from_fn(|i| {
|
||||
let mid = (i * xs.len() / 32 + (i + 1) * xs.len() / 32) / 2;
|
||||
[xs[mid] as f32, ys[mid] as f32]
|
||||
});
|
||||
assert!(elasticity(&raw) > 10.0, "the synthetic scene must reproduce the step: {}", elasticity(&raw));
|
||||
let tone = fit_tone(pairs).unwrap();
|
||||
let knots = tone.knots();
|
||||
assert!(elasticity(knots) <= MAX_TONE_ELASTICITY + 1e-3, "{}", elasticity(knots));
|
||||
assert!(knots.iter().zip(&raw).all(|(a, b)| a[0] == b[0]), "only the outputs move");
|
||||
for w in knots.windows(2) {
|
||||
assert!(w[1][1] >= w[0][1]);
|
||||
}
|
||||
// A curve within the bound is left alone.
|
||||
let smooth: [[f32; 2]; 32] = std::array::from_fn(|i| {
|
||||
let x = 0.01 * 1.15f32.powi(i as i32);
|
||||
[x, 0.9 * (x / (x + 0.18))]
|
||||
});
|
||||
assert_eq!(limit_elasticity(smooth, &[]), Some(smooth));
|
||||
}
|
||||
|
||||
/// A step at the top of the curve reaches its end: the pinned window and the free end are both under the bound, and
|
||||
/// the pixel pairs choose between them.
|
||||
#[test]
|
||||
fn a_step_at_the_curves_end_is_repaired_from_the_side_the_pixels_support() {
|
||||
let x = |i: usize| 0.01 * 1.15f64.powi(i as i32);
|
||||
let stepped: [[f32; 2]; 32] = std::array::from_fn(|i| [x(i) as f32, (0.3 * x(i) * if i >= 30 { 3.0 } else { 1.0 }) as f32]);
|
||||
let pairs = |gain: f64| -> Vec<(f64, f64)> {
|
||||
(0..4000).map(|j| x(0) * (x(31) / x(0)).powf(f64::from(j) / 3999.0)).map(|x| (x, 0.3 * x * if x >= 0.62 { gain } else { 1.0 })).collect()
|
||||
};
|
||||
// The pixels follow the curve below the step: the end is left free and the top knots drop to the repair slope.
|
||||
let free = limit_elasticity(stepped, &pairs(1.0)).unwrap();
|
||||
assert_eq!(free[..30], stepped[..30], "the curve up to the step is kept");
|
||||
let (u29, v29) = (x(29).ln(), (f64::from(stepped[29][1]) + ELASTICITY_BLACK).ln());
|
||||
for k in 30..32 {
|
||||
let limit = (v29 + TONE_REPAIR_ELASTICITY * (x(k).ln() - u29)).exp() - ELASTICITY_BLACK;
|
||||
assert!(f64::from(free[k][1]) <= limit + 1e-6, "knot {k}: {} over {limit}", free[k][1]);
|
||||
}
|
||||
// The pixels do sit above the step: the window stays pinned to the top knot.
|
||||
let pinned = limit_elasticity(stepped, &pairs(3.0)).unwrap();
|
||||
assert_eq!(pinned[31], stepped[31], "the end the pixels support is kept");
|
||||
assert!(pinned[29][1] > stepped[29][1] && pinned[30][1] < stepped[30][1], "the step is spread over the window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
@@ -661,7 +730,7 @@ fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() {
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
|
||||
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
|
||||
(proxies(&raw, &bytes, &t, PROXY, 0.0, None, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens), None))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -928,7 +997,7 @@ fn corpus_cr3_gets_a_framing_aligned_camera_look() {
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0;
|
||||
let look = fit_preview(&raw, &bytes, &transform, None, None).0;
|
||||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -965,7 +1034,7 @@ fn corpus_uses_the_bundled_profile(file: &str, model: &str) {
|
||||
let lens = lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted");
|
||||
let look = fit_preview(&raw, &bytes, &transform, lens.as_ref(), None).0.expect("fit accepted");
|
||||
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
|
||||
for (row, want) in look.matrix.0.iter().zip(expected.0) {
|
||||
for (got, want) in row.iter().zip(want) {
|
||||
@@ -1346,9 +1415,9 @@ fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() {
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look");
|
||||
let look = fit_preview(&raw, &bytes, &transform, None, None).0.expect("a camera look");
|
||||
// the look fitted to the JPEG, as before this change
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None, None).unwrap();
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
|
||||
@@ -1978,20 +2047,22 @@ fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() {
|
||||
assert!(look.is_some() && !t.matrix_is_fallback);
|
||||
}
|
||||
|
||||
/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG,
|
||||
/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the
|
||||
/// same file with a tinted JPEG gets nothing from this step.
|
||||
#[test]
|
||||
fn monochrome_look_reads_the_files_black_and_white_jpeg() {
|
||||
/// A synthetic ILCE-7M4 raw (it has a bundled camera profile; demosaiced grey `sensor` levels) and its file bytes:
|
||||
/// a minimal TIFF holding the camera JPEG, a bright camera curve on the `scene` luminance as an 8-bit sRGB JPEG in
|
||||
/// `tint` (grey for `[1.0; 3]`).
|
||||
fn grey_raw_with_jpeg(
|
||||
(w, h): (usize, usize),
|
||||
sensor: impl Fn(usize, usize) -> f32,
|
||||
scene: impl Fn(usize, usize) -> f32,
|
||||
tint: [f32; 3],
|
||||
) -> (RawImage, Vec<u8>) {
|
||||
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect};
|
||||
let (w, h) = (192usize, 128usize);
|
||||
let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
|
||||
let raw = RawImage {
|
||||
format: RawFormat::Arw,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 3,
|
||||
data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()),
|
||||
data: RawData::F32((0..w * h).flat_map(|i| [sensor(i % w, i / w); 3]).collect()),
|
||||
cfa: None,
|
||||
bits: 16,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
@@ -2007,38 +2078,146 @@ fn monochrome_look_reads_the_files_black_and_white_jpeg() {
|
||||
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let v = 1.0 - (-3.0 * scene(i % w, i / w)).exp();
|
||||
tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
})
|
||||
.collect();
|
||||
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
|
||||
let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
|
||||
// II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it
|
||||
let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] {
|
||||
tiff.extend(tag.to_le_bytes());
|
||||
tiff.extend(4u16.to_le_bytes());
|
||||
tiff.extend(1u32.to_le_bytes());
|
||||
tiff.extend(value.to_le_bytes());
|
||||
}
|
||||
tiff.extend(0u32.to_le_bytes());
|
||||
assert_eq!(tiff.len(), 38);
|
||||
tiff.extend(jpeg);
|
||||
(raw, tiff)
|
||||
}
|
||||
|
||||
/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 whose camera JPEG is black and white gets the tone curve,
|
||||
/// on the colour of the transform it is given; the same file with a tinted JPEG gets nothing from this step.
|
||||
#[test]
|
||||
fn monochrome_look_reads_the_files_black_and_white_jpeg() {
|
||||
let (w, h) = (192usize, 128usize);
|
||||
let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
|
||||
let (raw, grey) = grey_raw_with_jpeg((w, h), level, level, [1.0; 3]);
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
// the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG
|
||||
let file = |tint: [f32; 3]| {
|
||||
let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let v = 1.0 - (-3.0 * level(i % w, i / w)).exp();
|
||||
tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
})
|
||||
.collect();
|
||||
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
|
||||
let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
|
||||
// II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it
|
||||
let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] {
|
||||
tiff.extend(tag.to_le_bytes());
|
||||
tiff.extend(4u16.to_le_bytes());
|
||||
tiff.extend(1u32.to_le_bytes());
|
||||
tiff.extend(value.to_le_bytes());
|
||||
}
|
||||
tiff.extend(0u32.to_le_bytes());
|
||||
assert_eq!(tiff.len(), 38);
|
||||
tiff.extend(jpeg);
|
||||
tiff
|
||||
};
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG");
|
||||
let look = monochrome_look(&raw, &grey, &t, None, None).expect("a tone curve from the black-and-white JPEG");
|
||||
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour");
|
||||
let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let mid = curve.apply(0.25);
|
||||
assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}");
|
||||
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white");
|
||||
let (_, tinted) = grey_raw_with_jpeg((w, h), level, level, [1.0, 0.9, 0.8]);
|
||||
assert!(monochrome_look(&raw, &tinted, &t, None, None).is_none(), "a tinted JPEG is not black and white");
|
||||
}
|
||||
|
||||
/// The look is fitted on the sensor as the render shows it, with the file's embedded vignetting correction: the
|
||||
/// camera corrected its JPEG, so a curve fitted to the uncorrected sensor learns that brightening, and the render
|
||||
/// then applies it twice (X-S10 RAFs up to 0.3 EV brighter than their camera JPEGs).
|
||||
#[test]
|
||||
fn the_look_is_fitted_on_the_vignetting_corrected_sensor() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
|
||||
let (w, h) = (192usize, 128usize);
|
||||
let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 };
|
||||
let gain = |x: usize, y: usize| {
|
||||
let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32
|
||||
};
|
||||
let scene = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
|
||||
let (raw, bytes) = grey_raw_with_jpeg((w, h), |x, y| scene(x, y) / gain(x, y), scene, [1.0; 3]);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() };
|
||||
let camera = 1.0 - (-0.75f32).exp();
|
||||
let at_quarter = |embedded: Option<&EmbeddedLens>| {
|
||||
let look = monochrome_look(&raw, &bytes, &t, None, embedded).expect("a tone curve");
|
||||
ToneMap::camera(&look.tone, 0.0, 0.0, 0.0).apply(0.25)
|
||||
};
|
||||
let (corrected, uncorrected) = (at_quarter(Some(&lens)), at_quarter(None));
|
||||
assert!((corrected - camera).abs() < 0.03, "the camera's curve at 0.25: {corrected} vs {camera}");
|
||||
assert!(uncorrected > camera + 0.03, "without the correction the curve brightens: {uncorrected} vs {camera}");
|
||||
}
|
||||
|
||||
/// The colour fit gets the embedded vignetting correction too ([`starting_colour`] forwards it to every fit): the
|
||||
/// tinted camera JPEG of the vignetted sensor takes the colour path, not the black-and-white one.
|
||||
#[test]
|
||||
fn the_colour_look_is_fitted_on_the_vignetting_corrected_sensor() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
|
||||
let (w, h) = (192usize, 128usize);
|
||||
let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 };
|
||||
let gain = |x: usize, y: usize| {
|
||||
let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32
|
||||
};
|
||||
let scene = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
|
||||
let (raw, bytes) = grey_raw_with_jpeg((w, h), |x, y| scene(x, y) / gain(x, y), scene, [1.0, 0.9, 0.8]);
|
||||
let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() };
|
||||
let at_quarter = |embedded: Option<&EmbeddedLens>| {
|
||||
let (_, _, look) = starting_colour(&mut raw.clone(), &bytes, None, embedded);
|
||||
let look = look.expect("a colour look");
|
||||
assert!(look.matrix != Mat3::IDENTITY || look.hue_sat.is_some(), "the colour fit, not the black-and-white one");
|
||||
ToneMap::camera(&look.tone, 0.0, 0.0, 0.0).apply(0.25)
|
||||
};
|
||||
let (corrected, uncorrected) = (at_quarter(Some(&lens)), at_quarter(None));
|
||||
assert!(uncorrected > corrected + 0.03, "without the correction the curve brightens: {uncorrected} vs {corrected}");
|
||||
}
|
||||
|
||||
/// A lens-warped sensor proxy gets the vignetting gain of the position it samples, as the render applies it: the
|
||||
/// vignetted sensor, corrected, gives the proxy of the same sensor without vignetting.
|
||||
#[test]
|
||||
fn the_vignetting_correction_is_applied_before_the_lens_warp() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
|
||||
// twice the 384 px proxy edge: the demosaiced proxy of this linear raw is then the size a warp needs (bins of 2)
|
||||
let (w, h) = (768usize, 512usize);
|
||||
let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 };
|
||||
let gain = |x: usize, y: usize| {
|
||||
let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32
|
||||
};
|
||||
// a texture for the warped candidate to line up with: the JPEG shows the sensor's centre zoomed by 1 / 0.8
|
||||
let texture = |x: f64, y: f64| 0.2 * (1.0 + 0.3 * ((x / 48.0).floor() + (y / 48.0).floor()).rem_euclid(2.0)) as f32;
|
||||
let warp = LensGeometry { k: [0.8, 0.0, 0.0, 0.0], center: [0.5, 0.5] };
|
||||
let (cx, cy) = ((w - 1) as f64 / 2.0, (h - 1) as f64 / 2.0);
|
||||
let jpeg = |x: usize, y: usize| texture(cx + (x as f64 - cx) * 0.8, cy + (y as f64 - cy) * 0.8);
|
||||
let sensor_proxy = |vignetted: bool, embedded: Option<&EmbeddedLens>| {
|
||||
let sensor = |x: usize, y: usize| texture(x as f64, y as f64) / if vignetted { gain(x, y) } else { 1.0 };
|
||||
let (raw, bytes) = grey_raw_with_jpeg((w, h), sensor, jpeg, [1.0; 3]);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let (plain, _, _) = proxies(&raw, &bytes, &t, PROXY, 0.0, None, embedded).unwrap();
|
||||
let (sensor, _, _) = proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&warp), embedded).unwrap();
|
||||
assert!(sensor.data.iter().zip(&plain.data).any(|(a, b)| a != b), "the warped candidate is chosen");
|
||||
sensor
|
||||
};
|
||||
let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() };
|
||||
let (corrected, expected) = (sensor_proxy(true, Some(&lens)), sensor_proxy(false, None));
|
||||
let worst = corrected.data.iter().zip(&expected.data).map(|(a, b)| (a[1] / b[1] - 1.0).abs()).filter(|d| d.is_finite()).fold(0.0, f32::max);
|
||||
assert!(worst < 0.02, "largest deviation {worst}");
|
||||
}
|
||||
|
||||
/// [`alignment`] leaves out the pixels a proxy lacks (NaN outside a lens-warped one): read as black, they made a
|
||||
/// false edge around the border, and the warped candidate lost against a worse unwarped one.
|
||||
#[test]
|
||||
fn alignment_leaves_out_missing_pixels() {
|
||||
let (w, h) = (48usize, 32usize);
|
||||
let mut a = Rgb32f::new(w, h);
|
||||
for (i, p) in a.data.iter_mut().enumerate() {
|
||||
*p = [0.1 + 0.05 * ((i * 7919) % 13) as f32; 3];
|
||||
}
|
||||
let mut b = a.clone();
|
||||
for (i, p) in b.data.iter_mut().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
if x < 4 || y < 4 || x >= w - 4 || y >= h - 4 {
|
||||
*p = [f32::NAN; 3];
|
||||
}
|
||||
}
|
||||
assert!(alignment(&a, &b) > 0.999, "{}", alignment(&a, &b));
|
||||
}
|
||||
|
||||
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
|
||||
|
||||
@@ -133,7 +133,106 @@ pub(super) fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
|
||||
return None;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
CameraTone::new(limit_elasticity(knots, &pairs)?)
|
||||
}
|
||||
|
||||
/// Largest log-log slope (elasticity, d ln y / d ln x with [`ELASTICITY_BLACK`] added to y) a fitted tone curve may
|
||||
/// have between two knots. A much steeper segment is not a camera curve but two pixel populations the camera renders at
|
||||
/// different brightness for the same scene luminance (by hue): quantile matching puts all of one population's scene
|
||||
/// range below the other's JPEG range and joins them with a near-vertical step, which amplifies the luminance noise of
|
||||
/// every pixel on it and renders as contours and blotches. On a dark ISO 1100 D3400 frame of saturated blue and red
|
||||
/// (issue #1904; reds 1.55x brighter in the JPEG than blues of the same scene luminance) the step had elasticity 12-28,
|
||||
/// and the render's fine luminance noise in the blue area was 1.46x that of Adobe's DNG of the same frame; with the
|
||||
/// bound it is 0.99x. Of 514 fitted looks on the user samples the median curve's steepest segment is 2.1 and the 95th
|
||||
/// percentile 3.6. The smoothed window ends where its end-to-end slope is first under the bound, so a looser bound
|
||||
/// (5 or 6) only spreads the D3400 step over a few knots and leaves the noise as it was.
|
||||
pub(super) const MAX_TONE_ELASTICITY: f64 = 4.0;
|
||||
/// Log-log slope of a curve end left free by [`limit_elasticity`]: about the median steepest segment of fitted looks
|
||||
/// (2.1), so the knots past the step follow an ordinary camera curve. At the bound itself (4) the D3400 frame's fine
|
||||
/// luminance noise stays 1.2x Adobe's; at 2 it is 0.99x.
|
||||
pub(super) const TONE_REPAIR_ELASTICITY: f64 = 2.0;
|
||||
/// Display level added to both ends of a segment's elasticity, so a toe rising from black is not counted as infinitely
|
||||
/// steep.
|
||||
pub(super) const ELASTICITY_BLACK: f64 = 0.002;
|
||||
|
||||
/// `knots` with every segment steeper than [`MAX_TONE_ELASTICITY`] smoothed. The knots inside the smallest window
|
||||
/// around the segment (widened a knot on each side at a time) whose end-to-end elasticity is under the bound move onto
|
||||
/// the straight log-log line between the window's ends, so the step is spread over the scene range of the populations it
|
||||
/// joined. A window that reaches an end of the curve is pinned there, which can drag most of the curve onto one line
|
||||
/// (a step at the top of a Coolpix P340 curve pulled knots 3-29 up, held-out error +4 dE00), so it is weighed against
|
||||
/// leaving that end free: the knots past the segment on that side move onto [`TONE_REPAIR_ELASTICITY`] from the
|
||||
/// segment's other knot instead. Of the two the one closer to `pairs` (absolute error) is kept. The curve outside the
|
||||
/// window and its monotonicity are kept. `None` when nothing brings the curve under the bound. Knots within the bound are returned as
|
||||
/// they are.
|
||||
pub(super) fn limit_elasticity(mut knots: [[f32; 2]; 32], pairs: &[(f64, f64)]) -> Option<[[f32; 2]; 32]> {
|
||||
let (bound, repair) = (MAX_TONE_ELASTICITY, TONE_REPAIR_ELASTICITY);
|
||||
let at = |k: &[[f32; 2]; 32], i: usize| (f64::from(k[i][0]).ln(), (f64::from(k[i][1]) + ELASTICITY_BLACK).ln());
|
||||
let slope = |k: &[[f32; 2]; 32], a: usize, b: usize| {
|
||||
let ((ua, va), (ub, vb)) = (at(k, a), at(k, b));
|
||||
(vb - va) / (ub - ua)
|
||||
};
|
||||
let level = |v: f64| (v.exp() - ELASTICITY_BLACK).max(0.0) as f32;
|
||||
let error = |k: &[[f32; 2]; 32]| {
|
||||
let tone = CameraTone::new(*k)?;
|
||||
Some(pairs.iter().map(|&(x, y)| (f64::from(tone.apply(x as f32)) - y).abs()).sum::<f64>())
|
||||
};
|
||||
for _ in 0..64 {
|
||||
let Some(i) = (0..31).find(|&i| slope(&knots, i, i + 1) > bound) else { return Some(knots) };
|
||||
let (mut lo, mut hi) = (i, i + 1);
|
||||
while slope(&knots, lo, hi) > bound && !(lo == 0 && hi == 31) {
|
||||
lo = lo.saturating_sub(1);
|
||||
hi = (hi + 1).min(31);
|
||||
}
|
||||
let mut pinned = knots;
|
||||
let pinned_ok = slope(&knots, lo, hi) <= bound;
|
||||
if pinned_ok {
|
||||
let ((ua, va), (ub, vb)) = (at(&knots, lo), at(&knots, hi));
|
||||
for k in lo + 1..hi {
|
||||
pinned[k][1] = level(va + (vb - va) * (f64::from(knots[k][0]).ln() - ua) / (ub - ua));
|
||||
}
|
||||
}
|
||||
// A window that reaches an end of the curve can leave that end free instead: the knots below the segment rise
|
||||
// onto the repair slope down from knot i + 1, or the knots above it drop onto it up from knot i.
|
||||
let mut free = None;
|
||||
if lo == 0 || hi == 31 {
|
||||
let mut k2 = knots;
|
||||
if lo == 0 {
|
||||
let (ub, vb) = at(&knots, i + 1);
|
||||
for k in 0..=i {
|
||||
let (u, v) = at(&knots, k);
|
||||
k2[k][1] = level(v.max(vb - repair * (ub - u)));
|
||||
}
|
||||
} else {
|
||||
let (ua, va) = at(&knots, i);
|
||||
for k in i + 1..32 {
|
||||
let (u, v) = at(&knots, k);
|
||||
k2[k][1] = level(v.min(va + repair * (u - ua)));
|
||||
}
|
||||
}
|
||||
free = Some(k2);
|
||||
}
|
||||
let next = match (pinned_ok, free) {
|
||||
(true, Some(free)) => {
|
||||
if error(&free).unwrap_or(f64::INFINITY) < error(&pinned).unwrap_or(f64::INFINITY) {
|
||||
free
|
||||
} else {
|
||||
pinned
|
||||
}
|
||||
}
|
||||
(true, None) => pinned,
|
||||
(false, Some(free)) => free,
|
||||
(false, None) => return None,
|
||||
};
|
||||
if lightcraft_pipeline::profiling() {
|
||||
let moved: Vec<usize> = (0..32).filter(|&k| next[k][1] != knots[k][1]).collect();
|
||||
eprintln!("[profile] camera tone: elasticity {:.1} at scene {:.4}, knots {moved:?} moved", slope(&knots, i, i + 1), knots[i][0]);
|
||||
}
|
||||
if next == knots {
|
||||
return None;
|
||||
}
|
||||
knots = next;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> Option<f64> {
|
||||
@@ -182,5 +281,5 @@ pub(super) fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraT
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
CameraTone::new(limit_elasticity(knots, &pairs)?)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,80 @@ fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings
|
||||
|
||||
const CROP_ANGLE: &str = "crop.angle";
|
||||
|
||||
/// Whether a white balance mode renders its own white instead of the numbers stored on the photo: `As Shot`
|
||||
/// renders the source's as-shot white, and on a raw edited in Kelvin a named preset renders its own Kelvin / tint
|
||||
/// ([`WbMode::preset`]). Either way a number written into `temp` / `tint` is stored but read by nothing, so the
|
||||
/// panel shows the number while the render uses the mode's white. `Custom` reads its stored numbers, and so does
|
||||
/// `Auto` on such a raw: its white is resolved and stored when the mode is set (`develop.wb`), not at render time.
|
||||
fn wb_mode_ignores_its_numbers(mode: WbMode, info: &lightcraft_pipeline::SourceInfo) -> bool {
|
||||
mode == WbMode::AsShot || (info.raw && !info.relative_wb && mode.preset().is_some())
|
||||
}
|
||||
|
||||
/// Bring a photo's settings onto the scale and mode a white balance partial names, so a one-field partial means
|
||||
/// what it says (issue #2910, and its review). `None` when the partial needs no such preparation: it names both
|
||||
/// numbers, neither, or a mode of its own. [`merge_settings_for_photo`] and `preset.apply` both start here, and
|
||||
/// both finish with `normalize_wb` so the photo ends on the scale it is edited on.
|
||||
pub(crate) fn prepare_photo_for_wb_partial(d: &DevelopSettings, partial: &Value, info: &lightcraft_pipeline::SourceInfo) -> Option<DevelopSettings> {
|
||||
let mut d = (*d).clone();
|
||||
let wb = partial.get("wb").and_then(Value::as_object);
|
||||
// the partial names one of the two numbers, and not both or neither
|
||||
let one_field = wb.is_some_and(|wb| wb.contains_key("temp") != wb.contains_key("tint"));
|
||||
if !one_field {
|
||||
return None;
|
||||
}
|
||||
// a photo whose source is edited in Kelvin is the one with two scales to trip over
|
||||
let kelvin_source = info.raw && !info.relative_wb;
|
||||
// the scale the partial's numbers are written on: an unmentioned scale means the relative scale, as `merged` reads it
|
||||
let partial_scale = match wb.as_ref().and_then(|wb| wb.get("scale")).and_then(Value::as_str) {
|
||||
Some("kelvin") => lightcraft_develop::WbScale::Kelvin,
|
||||
_ => lightcraft_develop::WbScale::Legacy,
|
||||
};
|
||||
// put the photo onto the scale it is edited on: its values marked Kelvin, or a look still on the relative
|
||||
// scale converted to the Kelvin / tint that renders the same
|
||||
lightcraft_pipeline::local::normalize_wb(&mut d, info);
|
||||
// a mode that answers its own white would swallow the partial's number: write the white the photo really
|
||||
// renders as Custom values, so the number means what it says and the other field carries that white's look
|
||||
if !wb.is_some_and(|wb| wb.contains_key("mode")) && wb_mode_ignores_its_numbers(d.wb.mode, info) {
|
||||
let (t, tint) = lightcraft_pipeline::local::effective_wb(info, &d);
|
||||
(d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, t, tint);
|
||||
}
|
||||
if kelvin_source && partial_scale == lightcraft_develop::WbScale::Legacy {
|
||||
// and onto the partial's scale, so the field the partial leaves out is the photo's own look read there
|
||||
let as_shot = (info.as_shot_temp, info.as_shot_tint);
|
||||
let (kt, ktint) = lightcraft_pipeline::local::effective_wb(info, &d);
|
||||
if let Some((lt, ltint)) = lightcraft_pipeline::local::kelvin_to_legacy(kt, ktint, as_shot) {
|
||||
(d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale) = (WbMode::Custom, lt, ltint, lightcraft_develop::WbScale::Legacy);
|
||||
}
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Merge a partial settings JSON into a photo's settings (issue #2910). `DevelopSettings::merged` keeps a white
|
||||
/// balance that names no scale on the relative scale; on a photo edited in Kelvin the field the partial does not
|
||||
/// name (a partial with only `temp`, or only `tint`) would then be read on the other scale, giving a white neither
|
||||
/// the photo nor the partial asked for. A one-field white balance partial is therefore resolved against the photo
|
||||
/// first: the photo's white balance is brought onto the partial's scale with the photo's as-shot white
|
||||
/// (`kelvin_to_legacy` / `legacy_to_kelvin` via `normalize_wb`), the partial merges unchanged, and the result lands
|
||||
/// back on the photo's editing scale (`normalize_wb`), where the kept field carries the look the photo had.
|
||||
/// Partials naming both fields, or neither, mean the same as before. A photo whose white balance mode renders its
|
||||
/// own white — `As Shot`, or a named preset on a raw edited in Kelvin — has that white written out as Custom values
|
||||
/// first ([`wb_mode_ignores_its_numbers`]), because a mode that answers its own white would otherwise store the
|
||||
/// partial's number without any render reading it; a partial that names a `mode` itself stays authoritative.
|
||||
pub(crate) fn merge_settings_for_photo(
|
||||
d: &DevelopSettings,
|
||||
partial: &Value,
|
||||
info: &lightcraft_pipeline::SourceInfo,
|
||||
) -> std::result::Result<DevelopSettings, serde_json::Error> {
|
||||
let prepared = prepare_photo_for_wb_partial(d, partial, info);
|
||||
let on_kelvin_scale = prepared.is_some() && info.raw && !info.relative_wb;
|
||||
let mut out = prepared.as_ref().unwrap_or(d).merged(partial)?;
|
||||
if on_kelvin_scale {
|
||||
// and the result back onto the scale this photo is edited on
|
||||
lightcraft_pipeline::local::normalize_wb(&mut out, info);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
@@ -256,8 +330,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.merge", "Apply Settings JSON", [], None, "{settings: partial DevelopSettings JSON, label?}", has_active, |s, p| {
|
||||
let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?;
|
||||
let label = str_param(p, "label").unwrap_or("Edit").to_string();
|
||||
let id = active(s, "develop.merge")?;
|
||||
let info = s.source_info(id);
|
||||
edit(s, "develop.merge", &label, |d| {
|
||||
*d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?;
|
||||
*d = merge_settings_for_photo(d, &partial, &info).map_err(|e| bad("develop.merge", e.to_string()))?;
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -1016,7 +1092,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| s.develop_op(id, preset.apply(&d, amount), &format!("Preset: {}", preset.name)))
|
||||
.filter_map(|(id, d)| {
|
||||
// a hand-written preset may name one white balance number only, and then it means the same as
|
||||
// a partial: bring the photo onto that scale first, with its own as-shot white, and put the
|
||||
// result back on the scale the photo is edited on (issue #2910) — the merge `develop.merge` uses
|
||||
let info = s.source_info(id);
|
||||
let prepared = prepare_photo_for_wb_partial(&d, &preset.settings, &info);
|
||||
let on_kelvin_scale = prepared.is_some() && info.raw && !info.relative_wb;
|
||||
let base: &DevelopSettings = prepared.as_ref().unwrap_or(&d);
|
||||
let mut next = preset.apply(base, amount);
|
||||
if on_kelvin_scale {
|
||||
lightcraft_pipeline::local::normalize_wb(&mut next, &info);
|
||||
}
|
||||
s.develop_op(id, next, &format!("Preset: {}", preset.name))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?;
|
||||
ok()
|
||||
|
||||
@@ -320,9 +320,13 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||
|
||||
/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale.
|
||||
/// So do preview-only raws: their proxy is the embedded JPEG, read in the colour space the raw
|
||||
/// declares, which a look version bump can change too (#2654).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool {
|
||||
photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG"))
|
||||
photo.fitted_look()
|
||||
|| (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG"))
|
||||
|| (photo.kind == lightcraft_catalog::MediaKind::Raw && photo.preview_only.is_some())
|
||||
}
|
||||
|
||||
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
|
||||
@@ -691,6 +695,22 @@ mod tests {
|
||||
use crate::Session;
|
||||
use crate::media::SourceRef;
|
||||
|
||||
// Given a preview-only raw, its proxy goes stale with the look version: the embedded JPEG's colour
|
||||
// space can change with it (#2654); a rendered photo's proxy does not
|
||||
#[test]
|
||||
fn preview_only_raws_follow_the_look_version() {
|
||||
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
|
||||
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 6000, 4000, "2026-10-23T00:00:00");
|
||||
ph.kind = MediaKind::Raw;
|
||||
assert!(super::preview_fitted_look(&ph), "a developed CR3");
|
||||
ph.set_preview_only(Some("unsupported".into()));
|
||||
assert!(super::preview_fitted_look(&ph), "a preview-only CR3");
|
||||
ph.kind = MediaKind::Image;
|
||||
ph.format = "JPG".into();
|
||||
ph.set_preview_only(None);
|
||||
assert!(!super::preview_fitted_look(&ph), "a JPEG");
|
||||
}
|
||||
|
||||
// Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not
|
||||
// by reading the file again (here: a thumbnail job pointing at a file that is not there)
|
||||
#[test]
|
||||
|
||||
@@ -119,6 +119,26 @@ fn white_balance_scale_follows_the_files_matrices_then_the_cameras() {
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||||
}
|
||||
|
||||
/// A Fujifilm X-Trans III body reads Kelvin through the neutral pair its RAF stores (issue #730); without the pair
|
||||
/// in the file, or on a body with matrices (X-T3), nothing changes.
|
||||
#[test]
|
||||
fn a_fujifilm_x_trans_iii_raf_reads_kelvin_through_its_neutral_pair() {
|
||||
use lightcraft_raw::{ColorData, RawFormat};
|
||||
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
|
||||
let a = [366.0f32 / 302.0, 1.0, 866.0 / 302.0];
|
||||
let d65 = [646.0f32 / 302.0, 1.0, 498.0 / 302.0];
|
||||
let pair = lightcraft_raw::kelvin_presets::KelvinPresets::from_neutral_pair(a, d65);
|
||||
assert!(pair.is_some());
|
||||
let with = ColorData { kelvin_presets: pair, ..Default::default() };
|
||||
let xt20 = WbScaleOf::new(RawFormat::Raf, &with, Some(d65), &meta("FUJIFILM", "X-T20"));
|
||||
assert!(!xt20.relative && xt20.scale_white.is_some());
|
||||
let (t, tint) = xt20.as_shot(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0));
|
||||
assert!((6000.0..7000.0).contains(&t) && tint.abs() < 25.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("RAF", "FUJIFILM X-T20"));
|
||||
let bare = WbScaleOf::new(RawFormat::Raf, &ColorData::default(), Some(d65), &meta("FUJIFILM", "X-T20"));
|
||||
assert!(bare.scale_white.is_none());
|
||||
}
|
||||
|
||||
/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source
|
||||
/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative
|
||||
/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets
|
||||
|
||||
@@ -220,3 +220,247 @@ fn white_balance_commands_write_kelvin_on_raws_whose_camera_has_matrices() {
|
||||
fn lightcraft_engine_effective(info: &lightcraft_pipeline::SourceInfo, d: &lightcraft_develop::DevelopSettings) -> (f64, f64) {
|
||||
lightcraft_pipeline::local::effective_wb(info, d)
|
||||
}
|
||||
|
||||
/// Issue #2910: a `develop.merge` white-balance partial that names only `temp` or only `tint` is resolved against
|
||||
/// the photo, so the field the partial does not name keeps the look it had instead of being re-read on the
|
||||
/// partial's scale. The partial's own number keeps the meaning an unmentioned scale always had (relative), and a
|
||||
/// partial that says `scale: "kelvin"` is read in Kelvin.
|
||||
#[test]
|
||||
fn one_field_wb_partial_merge_keeps_the_other_field_on_its_scale() {
|
||||
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode, WbScale};
|
||||
let mut s = Session::new();
|
||||
let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = camera.into();
|
||||
p.as_shot_wb = Some(as_shot);
|
||||
p.develop = std::sync::Arc::new(p.camera_defaults());
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let as_shot = (4986.0, -2.0);
|
||||
let kelvin = add(&mut s, "a.arw", "SONY ILCE-7M3", as_shot);
|
||||
let relative = add(&mut s, "b.arw", "SONY ILCE-200", (6500.0, 0.0));
|
||||
let dev = |s: &Session, id| (*s.develop_of(id).unwrap()).clone();
|
||||
let l2k = |t, ti| lightcraft_pipeline::local::legacy_to_kelvin(t, ti, as_shot);
|
||||
let k2l = |t, ti| lightcraft_pipeline::local::kelvin_to_legacy(t, ti, as_shot).unwrap();
|
||||
// two whites this close in (u, v) read as the same colour change; the bug's re-read of one kept field moves
|
||||
// the white by ~8e-4 here, so a same-white check with this tolerance catches it
|
||||
let close = |a: (f64, f64), b: (f64, f64)| {
|
||||
let (au, av) = lightcraft_color::cct::xy_to_uv(lightcraft_color::cct::temp_tint_to_xy(a.0, a.1));
|
||||
let (bu, bv) = lightcraft_color::cct::xy_to_uv(lightcraft_color::cct::temp_tint_to_xy(b.0, b.1));
|
||||
(au - bu).hypot(av - bv) < 2e-4
|
||||
};
|
||||
// a stored white balance equal to an expected conversion, forgiving the last bits of the two code paths and
|
||||
// the round-trip of the scale conversions; the bug moved the tint by 2+ units, far outside this
|
||||
let nearly = |wb: &lightcraft_develop::WhiteBalance, want: &(f64, f64), why: &str| {
|
||||
assert!((wb.temp - want.0).abs() < 1.0 && (wb.tint - want.1).abs() < 0.5, "{why}: {wb:?} vs {want:?}");
|
||||
};
|
||||
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
|
||||
|
||||
// a temp-only partial on a photo edited in Kelvin: the tint keeps its Kelvin look (the bug re-read it as a
|
||||
// relative shift and moved the green/magenta balance although the partial never touched tint)
|
||||
let edited = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::Custom, temp: 5000.0, tint: 12.0, scale: WbScale::Kelvin },
|
||||
..DevelopSettings::for_raw(as_shot.0, as_shot.1)
|
||||
};
|
||||
s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
let keep = k2l(5000.0, 12.0);
|
||||
assert_eq!(d.wb.scale, WbScale::Kelvin, "the photo goes back on the scale it is edited on: {:?}", d.wb);
|
||||
nearly(&d.wb, &l2k(5800.0, keep.1), "the partial's number keeps the meaning an unmarked scale always had");
|
||||
// restating the photo's own temperature (the value it carries on the scale an unmarked partial means) leaves
|
||||
// its white exactly where it was: on main the kept tint was re-read as a relative shift, drifting the white
|
||||
s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap();
|
||||
let look = lightcraft_engine_effective(&s.source_info(kelvin), &edited);
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": keep.0}}})).unwrap();
|
||||
let again = lightcraft_engine_effective(&s.source_info(kelvin), &dev(&s, kelvin));
|
||||
assert!(close(look, again), "a restated one-field partial leaves the white alone: {look:?} -> {again:?}");
|
||||
// and the reverse: a tint-only partial keeps the temperature's look
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"tint": -20.0}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
nearly(&d.wb, &l2k(keep.0, -20.0), "a tint-only partial keeps the temperature's look");
|
||||
// a partial that names its scale is read on it: the same temperature asked for in Kelvin, kept tint unharmed
|
||||
s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, 12.0, WbScale::Kelvin), "{:?}", d.wb);
|
||||
|
||||
// from As Shot as well: the as-shot tint keeps its meaning (the bug read it as a relative shift from as shot)
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "asShot"}}})).unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
nearly(&d.wb, &l2k(5800.0, 0.0), "the as-shot tint keeps its meaning");
|
||||
assert_eq!(d.wb.scale, WbScale::Kelvin);
|
||||
|
||||
// A mode that answers its own white (As Shot, or a named preset on a raw edited in Kelvin) ignores the numbers
|
||||
// stored on the photo, so a one-field partial naming its scale landed on a white balance that stored the number
|
||||
// where no render would read it: the panel showed 5800 while the photo stayed at as shot. The white the photo
|
||||
// really renders is written out as Custom values first, so the named number means what it says and the field the
|
||||
// partial leaves out carries that same white (review of #2952).
|
||||
let as_shot_photo = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: 4986.0, tint: -2.0, scale: WbScale::Kelvin },
|
||||
..DevelopSettings::for_raw(as_shot.0, as_shot.1)
|
||||
};
|
||||
s.set_develop(kelvin, as_shot_photo.clone(), "as shot").unwrap();
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &as_shot_photo), as_shot, "as shot renders the file's own white");
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, -2.0, WbScale::Kelvin), "{:?}", d.wb);
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (5800.0, -2.0), "the temperature the partial asked for renders");
|
||||
// the tint side of the same hole
|
||||
s.set_develop(kelvin, as_shot_photo.clone(), "as shot").unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"tint": 20.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, as_shot.0, 20.0, WbScale::Kelvin), "{:?}", d.wb);
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (as_shot.0, 20.0));
|
||||
// a named preset mode answers its own Kelvin white on such a raw, so its numbers are swallowed the same way
|
||||
let preset_photo = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::Cloudy, temp: 4986.0, tint: -2.0, scale: WbScale::Kelvin },
|
||||
..DevelopSettings::for_raw(as_shot.0, as_shot.1)
|
||||
};
|
||||
s.set_develop(kelvin, preset_photo.clone(), "cloudy").unwrap();
|
||||
let cloudy = lightcraft_engine_effective(&s.source_info(kelvin), &preset_photo);
|
||||
assert_ne!(cloudy, (4986.0, -2.0), "the preset mode renders its own white, not the stored numbers");
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, cloudy.1, WbScale::Kelvin), "{:?}", d.wb);
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (5800.0, cloudy.1), "the kept tint is that white's own tint");
|
||||
// `Auto` resolves its gray when the mode is set and reads its stored numbers back: its mode stays, nothing frozen
|
||||
let auto_photo = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::Auto, temp: 5100.0, tint: 3.0, scale: WbScale::Kelvin },
|
||||
..DevelopSettings::for_raw(as_shot.0, as_shot.1)
|
||||
};
|
||||
s.set_develop(kelvin, auto_photo, "auto").unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint), (WbMode::Auto, 5800.0, 3.0), "{:?}", d.wb);
|
||||
// a partial that names a mode stays authoritative: naming `asShot` puts the photo back on the file's white
|
||||
s.set_develop(kelvin, as_shot_photo.clone(), "as shot").unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "asShot", "temp": 5800.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, lightcraft_engine_effective(&s.source_info(kelvin), &d)), (WbMode::AsShot, as_shot), "{:?}", d.wb);
|
||||
// the same swallow on a source without the Kelvin scale (a raw with no matrices): the number was stored and read
|
||||
// by nothing there too, and now reaches the render
|
||||
s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap();
|
||||
let before = dev(&s, relative);
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(relative), &before), (6500.0, 0.0), "as shot on this body is 6500 / 0");
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap();
|
||||
let d = dev(&s, relative);
|
||||
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5800.0, 0.0, WbScale::Legacy), "{:?}", d.wb);
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(relative), &d), (5800.0, 0.0), "the number the partial named renders");
|
||||
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
|
||||
|
||||
// the other direction: an old relative Custom value (no scale) plus a Kelvin temp-only partial — the photo's
|
||||
// white balance is taken onto the Kelvin scale first, so the kept tint is the Kelvin value of the photo's look
|
||||
let old = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::Custom, temp: 5800.0, tint: 20.0, scale: WbScale::Legacy },
|
||||
..DevelopSettings::for_raw(6500.0, 0.0)
|
||||
};
|
||||
s.set_develop(kelvin, old.clone(), "old relative").unwrap();
|
||||
let keep = l2k(5800.0, 20.0);
|
||||
assert!(!close((5800.0, 20.0), (keep.0, keep.1)), "the two readings really differ on this camera, so the next check can catch the bug");
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin), "{:?}", d.wb);
|
||||
nearly(&d.wb, &(5800.0, keep.1), "the kept tint is the old relative tint carried onto the Kelvin scale");
|
||||
|
||||
// a partial naming both fields, or neither, behaves exactly as before
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 6500.0, "tint": 0.0}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!(d.wb.scale, WbScale::Legacy, "a partial without a scale keeps the relative meaning");
|
||||
assert_eq!(lightcraft_engine_effective(&s.source_info(kelvin), &d), (as_shot.0, as_shot.1), "6500 / 0 without a scale is as shot");
|
||||
// the whole white balance arrives on the photo's scale when the partial says both fields and the scale
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 5200.0, "tint": 7.0, "scale": "kelvin"}}})).unwrap();
|
||||
let d = dev(&s, kelvin);
|
||||
assert_eq!((d.wb.temp, d.wb.tint, d.wb.scale), (5200.0, 7.0, WbScale::Kelvin), "{:?}", d.wb);
|
||||
// a one-field partial on a photo edited on the relative scale (no Kelvin scale there): unchanged behaviour, stays relative
|
||||
s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap();
|
||||
let before = dev(&s, relative);
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap();
|
||||
let d = dev(&s, relative);
|
||||
assert_eq!((d.wb.temp, d.wb.tint, d.wb.scale), (5800.0, before.wb.tint, WbScale::Legacy), "{:?}", d.wb);
|
||||
// a non-white-balance partial merges through the same path unchanged
|
||||
let wb_before = dev(&s, relative).wb;
|
||||
s.execute("develop.merge", &json!({"settings": {"light": {"contrast": 20}}})).unwrap();
|
||||
let d = dev(&s, relative);
|
||||
assert_eq!(d.light.contrast, 20.0);
|
||||
assert_eq!(d.wb, wb_before, "the white balance is untouched");
|
||||
}
|
||||
|
||||
/// The issue asks for one photo-aware merge in every caller that writes settings onto a photo, and that includes
|
||||
/// `preset.apply`: a preset a person or an agent authors by hand can name a single white balance number, and on a
|
||||
/// photo edited in Kelvin that demoted the field it never mentioned to the relative scale, exactly as
|
||||
/// `develop.merge` did. Presets the app makes and the crs / Luminar importers name `mode`, `temp`, `tint` and
|
||||
/// `scale` together, and those keep their old path through `Preset::apply` → `apply_partial` unchanged.
|
||||
#[test]
|
||||
fn one_field_wb_preset_apply_keeps_the_other_field_on_its_scale() {
|
||||
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode, WbScale};
|
||||
let mut s = Session::new();
|
||||
let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = camera.into();
|
||||
p.as_shot_wb = Some(as_shot);
|
||||
p.develop = std::sync::Arc::new(p.camera_defaults());
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let as_shot = (4986.0, -2.0);
|
||||
let kelvin = add(&mut s, "preset-wb.arw", "SONY ILCE-7M3", as_shot);
|
||||
let l2k = |t, ti| lightcraft_pipeline::local::legacy_to_kelvin(t, ti, as_shot);
|
||||
let k2l = |t, ti| lightcraft_pipeline::local::kelvin_to_legacy(t, ti, as_shot).unwrap();
|
||||
let nearly = |wb: &lightcraft_develop::WhiteBalance, want: &(f64, f64), why: &str| {
|
||||
assert!((wb.temp - want.0).abs() < 1.0 && (wb.tint - want.1).abs() < 0.5, "{why}: {wb:?} vs {want:?}");
|
||||
};
|
||||
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
|
||||
let edited = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::Custom, temp: 5000.0, tint: 12.0, scale: WbScale::Kelvin },
|
||||
..DevelopSettings::for_raw(as_shot.0, as_shot.1)
|
||||
};
|
||||
s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap();
|
||||
|
||||
// a hand-written preset naming one number: the tint it never mentions keeps its Kelvin look, instead of the
|
||||
// old path that stored 5800 and re-read the photo's +12 tint as a relative shift
|
||||
s.presets.push(lightcraft_develop::Preset {
|
||||
id: "user.temp-only".into(),
|
||||
name: "Temp only".into(),
|
||||
group: "User".into(),
|
||||
settings: json!({"wb": {"temp": 5800.0}}),
|
||||
favorite: false,
|
||||
builtin: false,
|
||||
});
|
||||
s.execute("preset.apply", &json!({"id": "user.temp-only"})).unwrap();
|
||||
let d = (*s.develop_of(kelvin).unwrap()).clone();
|
||||
let keep = k2l(5000.0, 12.0);
|
||||
assert_eq!(d.wb.mode, WbMode::Custom, "{:?}", d.wb);
|
||||
assert_eq!(d.wb.scale, WbScale::Kelvin, "the preset left the photo on the scale it is edited on: {:?}", d.wb);
|
||||
nearly(&d.wb, &l2k(5800.0, keep.1), "the preset's number keeps the meaning an unmarked scale always had");
|
||||
// an amount below 100 % blends between the photo's own white and the preset's, on one scale, and never
|
||||
// mentions the other field at all
|
||||
s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap();
|
||||
s.execute("preset.apply", &json!({"id": "user.temp-only", "amount": 50})).unwrap();
|
||||
let half = (*s.develop_of(kelvin).unwrap()).clone();
|
||||
let full = l2k(5800.0, keep.1);
|
||||
assert_eq!(half.wb.scale, WbScale::Kelvin, "{:?}", half.wb);
|
||||
assert!(half.wb.temp < 5000.0 && half.wb.temp > full.0, "between the photo's own white and the preset's: {:?}", half.wb);
|
||||
nearly(&half.wb, &(half.wb.temp, 12.0), "the tint the preset never named stays exactly the photo's");
|
||||
// the same number through `develop.merge` gives the same white: that is the point of one shared merge
|
||||
s.set_develop(kelvin, edited.clone(), "kelvin edit").unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"temp": 5800.0}}})).unwrap();
|
||||
let merged = (*s.develop_of(kelvin).unwrap()).clone();
|
||||
nearly(&merged.wb, &(d.wb.temp, d.wb.tint), "preset apply and develop.merge agree on a one-field white balance");
|
||||
// a preset made from the photo names the whole white balance: its own path, unchanged
|
||||
s.execute("preset.create", &json!({"name": "My kelvin look", "groups": ["whiteBalance"]})).unwrap();
|
||||
let made = s.presets.iter().find(|p| p.name == "My kelvin look").cloned().unwrap();
|
||||
let look = (*s.develop_of(kelvin).unwrap()).clone();
|
||||
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "asShot"}}})).unwrap();
|
||||
s.execute("preset.apply", &json!({"id": made.id})).unwrap();
|
||||
let back = (*s.develop_of(kelvin).unwrap()).clone();
|
||||
assert_eq!(back.wb, look.wb, "a preset made from the photo puts its whole white balance back: {:?} vs {:?}", back.wb, look.wb);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ user fields (artist, copyright, title, caption, keywords, hierarchical keywords,
|
||||
|
||||
EXIF capture-time candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock
|
||||
with more than three colon-separated components is rejected as a whole, allowing the next valid candidate or XMP
|
||||
fallback to supply the capture time. The original tag remains available in All Metadata.
|
||||
fallback to supply the capture time. The original tag remains available in All Metadata. JPEG ICC data is exposed only when its APP2 chunks form a complete, consistent, uniquely numbered sequence;
|
||||
malformed or truncated ICC sequences leave unrelated readable metadata available and produce no profile.
|
||||
|
||||
## XMP namespaces
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
|
||||
if !b.starts_with(&[0xff, 0xd8]) {
|
||||
return out;
|
||||
}
|
||||
let mut icc_parts: Vec<(u8, Vec<u8>)> = Vec::new();
|
||||
let mut icc_parts: [Option<&[u8]>; 255] = [None; 255];
|
||||
let mut icc_total = None;
|
||||
let mut icc_invalid = false;
|
||||
let mut ext: Vec<(u32, Vec<u8>)> = Vec::new();
|
||||
let mut i = 2usize;
|
||||
while i + 4 <= b.len() {
|
||||
@@ -118,8 +120,11 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
|
||||
if len < 2 {
|
||||
break;
|
||||
}
|
||||
let end = (i + 2 + len).min(b.len());
|
||||
let payload = &b[i + 4..end];
|
||||
let Some(start) = i.checked_add(4) else { break };
|
||||
let Some(declared_end) = i.checked_add(2).and_then(|at| at.checked_add(len)) else { break };
|
||||
let complete = declared_end <= b.len();
|
||||
let end = declared_end.min(b.len());
|
||||
let Some(payload) = b.get(start..end) else { break };
|
||||
match marker {
|
||||
0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()),
|
||||
0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => {
|
||||
@@ -133,15 +138,64 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
|
||||
ext.push((off, p[40..].to_vec()));
|
||||
}
|
||||
}
|
||||
0xe2 if payload.starts_with(b"ICC_PROFILE\0") && payload.len() >= 14 => icc_parts.push((payload[12], payload[14..].to_vec())),
|
||||
0xe2 if payload.starts_with(b"ICC_PROFILE\0") => {
|
||||
if !complete || payload.len() < 14 {
|
||||
icc_invalid = true;
|
||||
} else if let Some(header) = payload.get(12..14) {
|
||||
let (Some(&sequence), Some(&total)) = (header.first(), header.get(1)) else {
|
||||
icc_invalid = true;
|
||||
i = end;
|
||||
continue;
|
||||
};
|
||||
if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) {
|
||||
icc_invalid = true;
|
||||
} else {
|
||||
icc_total = Some(total);
|
||||
if let Some(slot) = icc_parts.get_mut(usize::from(sequence - 1)) {
|
||||
if slot.is_some() {
|
||||
icc_invalid = true;
|
||||
} else if let Some(data) = payload.get(14..) {
|
||||
*slot = Some(data);
|
||||
} else {
|
||||
icc_invalid = true;
|
||||
}
|
||||
} else {
|
||||
icc_invalid = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
icc_invalid = true;
|
||||
}
|
||||
}
|
||||
0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]),
|
||||
_ => {}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
if !icc_parts.is_empty() {
|
||||
icc_parts.sort_by_key(|p| p.0);
|
||||
out.icc = Some(icc_parts.into_iter().flat_map(|p| p.1).collect());
|
||||
if let Some(total) = icc_total {
|
||||
let mut profile_len = 0usize;
|
||||
for chunk in icc_parts.iter().take(usize::from(total)) {
|
||||
let Some(bytes) = chunk else {
|
||||
icc_invalid = true;
|
||||
break;
|
||||
};
|
||||
let Some(next_len) = profile_len.checked_add(bytes.len()) else {
|
||||
icc_invalid = true;
|
||||
break;
|
||||
};
|
||||
profile_len = next_len;
|
||||
}
|
||||
let maximum_len = usize::from(total).checked_mul(65_519);
|
||||
if maximum_len.is_none_or(|maximum| profile_len > maximum) {
|
||||
icc_invalid = true;
|
||||
}
|
||||
if !icc_invalid {
|
||||
let mut profile = Vec::with_capacity(profile_len);
|
||||
for bytes in icc_parts.iter().take(usize::from(total)).flatten() {
|
||||
profile.extend_from_slice(bytes);
|
||||
}
|
||||
out.icc = Some(profile);
|
||||
}
|
||||
}
|
||||
if !ext.is_empty() {
|
||||
ext.sort_by_key(|p| p.0);
|
||||
@@ -230,6 +284,26 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
fn icc_seg(sequence: u8, total: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut payload = b"ICC_PROFILE\0".to_vec();
|
||||
payload.extend_from_slice(&[sequence, total]);
|
||||
payload.extend_from_slice(data);
|
||||
seg(0xe2, &payload)
|
||||
}
|
||||
|
||||
fn jpeg_with_icc(parts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut jpeg = vec![0xff, 0xd8];
|
||||
let packet = crate::write_xmp(&crate::Metadata { title: Some("retained".into()), ..Default::default() }, None);
|
||||
let mut xmp = XMP_SIG.to_vec();
|
||||
xmp.extend_from_slice(packet.as_bytes());
|
||||
jpeg.extend_from_slice(&seg(0xe1, &xmp));
|
||||
for part in parts {
|
||||
jpeg.extend_from_slice(part);
|
||||
}
|
||||
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
|
||||
jpeg
|
||||
}
|
||||
|
||||
pub(crate) fn sample_jpeg() -> Vec<u8> {
|
||||
let mut j = vec![0xff, 0xd8];
|
||||
j.extend(seg(0xe0, b"JFIF\0\x01\x02\0\0\x01\0\x01\0\0"));
|
||||
@@ -286,6 +360,49 @@ mod tests {
|
||||
assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_accepts_reordered_chunks() {
|
||||
let parts = [icc_seg(2, 2, b"world"), icc_seg(1, 2, b"hello")];
|
||||
let embedded = jpeg_segments(&jpeg_with_icc(&parts));
|
||||
assert_eq!(embedded.icc.as_deref(), Some(&b"helloworld"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_rejects_incomplete_or_incoherent_chunks() {
|
||||
let cases = [
|
||||
("missing", vec![icc_seg(1, 2, b"helloworld")]),
|
||||
("duplicate", vec![icc_seg(1, 2, b"hello"), icc_seg(1, 2, b"world")]),
|
||||
("zero sequence", vec![icc_seg(0, 1, b"helloworld")]),
|
||||
("zero total", vec![icc_seg(1, 0, b"helloworld")]),
|
||||
("out of range", vec![icc_seg(2, 1, b"helloworld")]),
|
||||
("gap", vec![icc_seg(1, 3, b"hello"), icc_seg(3, 3, b"world")]),
|
||||
("inconsistent totals", vec![icc_seg(1, 2, b"hello"), icc_seg(2, 3, b"world")]),
|
||||
("short header", vec![seg(0xe2, b"ICC_PROFILE\0\x01")]),
|
||||
];
|
||||
for (name, parts) in cases {
|
||||
let embedded = jpeg_segments(&jpeg_with_icc(&parts));
|
||||
assert!(embedded.icc.is_none(), "{name} ICC chunks must be rejected");
|
||||
assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("retained")), "{name}: unrelated XMP must remain readable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_rejects_truncated_segment_and_keeps_other_metadata() {
|
||||
let mut truncated = icc_seg(1, 1, b"profile");
|
||||
let declared = u16::from_be_bytes([truncated[2], truncated[3]]).saturating_add(1);
|
||||
truncated[2..4].copy_from_slice(&declared.to_be_bytes());
|
||||
let mut jpeg = vec![0xff, 0xd8];
|
||||
let xmp = crate::write_xmp(&crate::Metadata { title: Some("kept".into()), ..Default::default() }, None);
|
||||
let mut packet = XMP_SIG.to_vec();
|
||||
packet.extend_from_slice(xmp.as_bytes());
|
||||
jpeg.extend_from_slice(&seg(0xe1, &packet));
|
||||
jpeg.extend_from_slice(&truncated);
|
||||
|
||||
let embedded = jpeg_segments(&jpeg);
|
||||
assert!(embedded.icc.is_none());
|
||||
assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("kept")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_and_webp() {
|
||||
let exif = sample_exif(ByteOrder::Little);
|
||||
|
||||
@@ -140,6 +140,39 @@ pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// The relative-scale `(temp, tint)` that [`legacy_to_kelvin`] would read as the Kelvin `(temp, tint)` on a source
|
||||
/// whose real as-shot white is `as_shot`: the inverse of [`legacy_white`]'s composition. It runs the Bradford step
|
||||
/// backwards — the old target's cone response times the old reference's over the as-shot white's — and reads the
|
||||
/// result back through [`xy_to_temp_tint`]. `(6500, 0)` is the relative scale's as-shot reference, so the Kelvin
|
||||
/// white of the source itself maps exactly to it. `None` when the arithmetic has nowhere to go (a non-finite input,
|
||||
/// a white outside the chromaticity plane, or an as-shot cone response of zero, where the forward step discarded it).
|
||||
pub fn kelvin_to_legacy(temp: f64, tint: f64, as_shot: (f64, f64)) -> Option<(f64, f64)> {
|
||||
use lightcraft_color::{BRADFORD, Xy};
|
||||
if !(temp.is_finite() && tint.is_finite() && as_shot.0.is_finite() && as_shot.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if (temp - as_shot.0).abs() < 1e-9 && (tint - as_shot.1).abs() < 1e-9 {
|
||||
// the source's own white: the relative scale's as-shot reference, exactly
|
||||
return Some((6500.0, 0.0));
|
||||
}
|
||||
let cones = |t: f64, tint: f64| BRADFORD.apply(temp_tint_to_xy(t, tint).to_xyz());
|
||||
let (target, reference, shot) = (cones(temp, tint), cones(6500.0, 0.0), cones(as_shot.0, as_shot.1));
|
||||
let inverse = BRADFORD.inverse()?;
|
||||
let mut old = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
if shot[i].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
old[i] = target[i] * reference[i] / shot[i];
|
||||
}
|
||||
let xy = Xy::from_xyz(inverse.apply(old));
|
||||
if !(xy.x.is_finite() && xy.y.is_finite() && xy.y > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(xy);
|
||||
(t.is_finite() && tint.is_finite()).then_some((t, tint))
|
||||
}
|
||||
|
||||
/// Bring the white balance of `d` onto the scale `info` is edited on, before a new value is written into it: on a
|
||||
/// raw with a Kelvin scale the values are marked Kelvin, and ones still on the relative scale become the Custom
|
||||
/// Kelvin / tint that renders the same ([`legacy_to_kelvin`]; a preset mode's look is kept as that custom
|
||||
@@ -703,6 +736,7 @@ pub fn airlight_of(mut v: Vec<f32>) -> f32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_color::cct::xy_to_uv;
|
||||
|
||||
#[test]
|
||||
fn guided_preserves_step_edges_and_flattens_texture() {
|
||||
@@ -796,6 +830,70 @@ mod tests {
|
||||
scene_linear_pre(&mut img, &SourceInfo::default(), &s);
|
||||
assert!((img.get(1, 1)[2] - 0.6).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// [`kelvin_to_legacy`] inverts [`legacy_to_kelvin`] over the whole relative slider range and back: the
|
||||
/// round trip returns the start, in both directions, to the precision of the Kelvin / tint parametrisation.
|
||||
#[test]
|
||||
fn kelvin_to_legacy_inverts_legacy_to_kelvin() {
|
||||
for as_shot in [(4986.0, -2.0), (6500.0, 0.0), (3200.0, 14.0), (9500.0, -12.0)] {
|
||||
// the relative slider's whole reach: temp 0..100 (2000–13550 K around 6500), tint −150..150
|
||||
for pct in [0.0, 12.5, 25.0, 50.0, 75.0, 100.0] {
|
||||
for tint in [-150.0, -40.0, 0.0, 33.0, 150.0] {
|
||||
let (lt, ltint) = controls_relative(pct, tint);
|
||||
let (kt, ktint) = legacy_to_kelvin(lt, ltint, as_shot);
|
||||
// past the Kelvin parametrisation's own reach (its search runs 1000–50000 K) the forward
|
||||
// conversion has already clamped, so the pair is not expected to come back
|
||||
if !(1050.0..=49_000.0).contains(&kt) {
|
||||
continue;
|
||||
}
|
||||
let back = kelvin_to_legacy(kt, ktint, as_shot).unwrap();
|
||||
let (x1, y1) = xy_to_uv(temp_tint_to_xy(lt, ltint));
|
||||
let (x2, y2) = xy_to_uv(temp_tint_to_xy(back.0, back.1));
|
||||
// the pair may differ by more than the slider's own step at the extreme corners (the Kelvin
|
||||
// parametrisation is read back through a search), but never by much, and the white it renders
|
||||
// is the same
|
||||
assert!(
|
||||
(back.0 - lt).abs() <= lt.abs() * 0.01 && (back.1 - ltint).abs() < 0.5,
|
||||
"as shot {as_shot:?}: relative ({lt}, {ltint}) -> kelvin ({kt}, {ktint}) -> {back:?}"
|
||||
);
|
||||
assert!((x1 - x2).hypot(y1 - y2) < 1e-3, "as shot {as_shot:?}: relative ({lt}, {ltint}) -> kelvin ({kt}, {ktint}) -> {back:?}");
|
||||
}
|
||||
}
|
||||
// the other direction: every white the relative scale can hold maps back to a pair that renders the same
|
||||
for kt in [as_shot.0, 2500.0, 4200.0, 6500.0, 12000.0] {
|
||||
for ktint in [-60.0, 0.0, 45.0] {
|
||||
let Some((lt, ltint)) = kelvin_to_legacy(kt, ktint, as_shot) else { continue };
|
||||
// outside the slider's reach the relative scale has no value for this white: the forward
|
||||
// conversion would clamp, so the round trip is not expected to close there
|
||||
if !(2000.0..=13550.0).contains(<) || ltint.abs() > 150.0 {
|
||||
continue;
|
||||
}
|
||||
let (kt2, ktint2) = legacy_to_kelvin(lt, ltint, as_shot);
|
||||
let (x1, y1) = xy_to_uv(temp_tint_to_xy(kt, ktint));
|
||||
let (x2, y2) = xy_to_uv(temp_tint_to_xy(kt2, ktint2));
|
||||
assert!(
|
||||
(x1 - x2).hypot(y1 - y2) < 1e-3,
|
||||
"as shot {as_shot:?}: kelvin ({kt}, {ktint}) -> relative ({lt}, {ltint}) -> ({kt2}, {ktint2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
// the source's own white is the relative scale's as-shot reference, exactly
|
||||
assert_eq!(kelvin_to_legacy(as_shot.0, as_shot.1, as_shot), Some((6500.0, 0.0)));
|
||||
}
|
||||
// the exact inverse of the special case inside legacy_to_kelvin
|
||||
assert_eq!(kelvin_to_legacy(4986.0, -2.0, (4986.0, -2.0)), Some((6500.0, 0.0)));
|
||||
assert_eq!(legacy_to_kelvin(6500.0, 0.0, (4986.0, -2.0)), (4986.0, -2.0));
|
||||
// hostile numbers answer None, never a panic or a plausible lie
|
||||
assert_eq!(kelvin_to_legacy(f64::NAN, 0.0, (5000.0, 0.0)), None);
|
||||
assert_eq!(kelvin_to_legacy(5000.0, f64::INFINITY, (5000.0, 0.0)), None);
|
||||
assert_eq!(kelvin_to_legacy(5000.0, 0.0, (f64::NAN, 0.0)), None);
|
||||
}
|
||||
|
||||
/// The relative scale's `(temp, tint)` for a slider temperature at `pct` % (2000–7000 K fill the left half) and tint.
|
||||
fn controls_relative(pct: f64, tint: f64) -> (f64, f64) {
|
||||
let t = if pct <= 50.0 { 2000.0 + pct * 100.0 } else { 7000.0 + (pct - 50.0) * 131.0 };
|
||||
(t, tint)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
//! - The isotherms are not perpendicular to the curve in these coordinates: an offset also changes mired by
|
||||
//! [`ISOTHERM_TILT`] times the mired the same distance along the curve would.
|
||||
//!
|
||||
//! Fujifilm X-Trans III bodies (X-T20, X-T2, X-Pro2, X-E3, X-H1, X100F) store no such presets but the camera's own
|
||||
//! neutral multipliers for Illuminant A and D65 (`0xf00c`, [`KelvinPresets::from_neutral_pair`], tag number from
|
||||
//! a black-box `exiftool -v3`). The pair gives a two-point line in the same coordinates; mired is linear along it,
|
||||
//! isotherms are perpendicular to it, and tint is scaled by the CIE locus spacing. No other camera's data is used.
|
||||
//!
|
||||
//! The two constants come from the 14 Sony bodies that have both measured matrices and the presets (one CC0
|
||||
//! raw.pixls.us sample each for the presets, a grid of multipliers through `crate::spectral` for the matrices):
|
||||
//! the tilt measured 0.59–0.84 (mean 0.74), the tint spacing 0.90–1.04 of the locus's (mean 0.98, taken as 1).
|
||||
@@ -55,6 +60,11 @@ const MODELS: &[(&str, &str)] = &[
|
||||
("CANON", "PowerShot G1 X Mark III"),
|
||||
];
|
||||
|
||||
/// Fujifilm's X-Trans III bodies, whose raws carry the camera's own neutral multipliers for Illuminant A and D65
|
||||
/// (`vendor::raf`, tag `0xf00c`; see [`KelvinPresets::from_neutral_pair`]): checked on the files of each (X-T20,
|
||||
/// X-T2, X-Pro2, X-E3, X-H1, X100F), the Exif model as Fujifilm writes it.
|
||||
const FUJI_MODELS: &[&str] = &["X-T20", "X-T2", "X-Pro2", "X-E3", "X-H1", "X100F"];
|
||||
|
||||
/// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour
|
||||
/// matrices of its own or measured: see [`crate::spectral::find`], which comes first), from the file's presets or
|
||||
/// built-in ones ([`crate::kelvin_builtin`]).
|
||||
@@ -64,14 +74,16 @@ pub fn covers(make: Option<&str>, model: &str) -> bool {
|
||||
}
|
||||
let model = model.trim();
|
||||
let brand = make.and_then(|m| m.split_whitespace().next());
|
||||
MODELS.iter().any(|(b, m)| {
|
||||
let bare = model
|
||||
let bare_of = |b: &str| {
|
||||
model
|
||||
.get(..b.len())
|
||||
.zip(model.get(b.len()..))
|
||||
.filter(|(p, rest)| p.eq_ignore_ascii_case(b) && rest.starts_with(' '))
|
||||
.map_or(model, |(_, rest)| rest.trim_start());
|
||||
brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare.eq_ignore_ascii_case(m)
|
||||
})
|
||||
.map_or(model, |(_, rest)| rest.trim_start())
|
||||
};
|
||||
let listed = MODELS.iter().any(|(b, m)| brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare_of(b).eq_ignore_ascii_case(m));
|
||||
let fuji = brand.is_none_or(|b| b.eq_ignore_ascii_case("FUJIFILM")) && FUJI_MODELS.iter().any(|m| bare_of("FUJIFILM").eq_ignore_ascii_case(m));
|
||||
listed || fuji
|
||||
}
|
||||
|
||||
/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`).
|
||||
@@ -88,8 +100,17 @@ const MAX_POINTS: usize = 32;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KelvinPresets {
|
||||
points: Vec<(f32, [f32; 3])>,
|
||||
/// The camera's neutral multipliers for Illuminant A and D65 when the presets come from such a pair
|
||||
/// ([`Self::from_neutral_pair`]); the points are then the line between them at five temperatures.
|
||||
#[serde(default)]
|
||||
pair: Option<[[f32; 3]; 2]>,
|
||||
}
|
||||
|
||||
/// Illuminant A and D65 as chromaticities (CIE).
|
||||
const A_XY: Xy = Xy::new(0.44757, 0.40745);
|
||||
const D65_XY: Xy = Xy::new(0.31271, 0.32902);
|
||||
const POINT_KELVIN: [f32; 5] = [2500.0, 3200.0, 4500.0, 6000.0, 8500.0];
|
||||
|
||||
fn usable(m: [f32; 3]) -> bool {
|
||||
m.iter().all(|v| *v > 0.0 && v.is_finite())
|
||||
}
|
||||
@@ -113,7 +134,7 @@ impl KelvinPresets {
|
||||
let (a, b) = (log_point(w[0].1), log_point(w[1].1));
|
||||
w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1]
|
||||
});
|
||||
ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() })
|
||||
ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect(), pair: None })
|
||||
}
|
||||
|
||||
/// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …):
|
||||
@@ -135,6 +156,51 @@ impl KelvinPresets {
|
||||
KelvinPresets::new(kept)
|
||||
}
|
||||
|
||||
/// Presets from a camera's own neutral multipliers (red, green, blue, green = 1) for Illuminant A and D65, as
|
||||
/// Fujifilm stores them in `WB_GRBLevelsStandard`. Two whites fix the camera's response only on the line
|
||||
/// between them, and nothing but the colorimetry of the two lights is used to place a white off it: in
|
||||
/// `(ln R/G, ln B/G)` mired is linear along the line (extended past both ends), the isotherms are taken
|
||||
/// perpendicular to it (no tilt), and the tint is the Planckian locus's own CIE 1960 spacing per unit length
|
||||
/// plus the tint the two lights themselves have (A is on the locus, D65 is about +9.5 off it). No other camera's
|
||||
/// data is involved. `None` for unusable multipliers or two that make no curve.
|
||||
pub fn from_neutral_pair(a: [f32; 3], d65: [f32; 3]) -> Option<KelvinPresets> {
|
||||
if !usable(a) || !usable(d65) {
|
||||
return None;
|
||||
}
|
||||
let (a, d65) = ([a[0] / a[1], 1.0, a[2] / a[1]], [d65[0] / d65[1], 1.0, d65[2] / d65[1]]);
|
||||
let (pa, pd) = (log_point(a), log_point(d65));
|
||||
let (ma, md) = (1e6 / cct::xy_to_temp_tint(A_XY).0, 1e6 / cct::xy_to_temp_tint(D65_XY).0);
|
||||
let points = POINT_KELVIN.map(|k| {
|
||||
let f = (1e6 / k as f64 - ma) / (md - ma);
|
||||
let q = [pa[0] + f * (pd[0] - pa[0]), pa[1] + f * (pd[1] - pa[1])];
|
||||
(k, [q[0].exp() as f32, 1.0, q[1].exp() as f32])
|
||||
});
|
||||
let mut out = KelvinPresets::new(points)?;
|
||||
out.pair = Some([a, d65]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`Self::temp_tint`] for a neutral pair (see [`Self::from_neutral_pair`]).
|
||||
fn pair_temp_tint(pair: [[f32; 3]; 2], q: [f64; 2]) -> Option<(f64, f64)> {
|
||||
let (pa, pd) = (log_point(pair[0]), log_point(pair[1]));
|
||||
let d = [pd[0] - pa[0], pd[1] - pa[1]];
|
||||
let len = d[0].hypot(d[1]);
|
||||
// only a deserialized pair can repeat a point
|
||||
if !(len > 0.0 && len.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let t = [d[0] / len, d[1] / len];
|
||||
let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] };
|
||||
let r = [q[0] - pa[0], q[1] - pa[1]];
|
||||
let along = (r[0] * t[0] + r[1] * t[1]) / len;
|
||||
let off = r[0] * n[0] + r[1] * n[1];
|
||||
let ((ta, tint_a), (td, tint_d)) = (cct::xy_to_temp_tint(A_XY), cct::xy_to_temp_tint(D65_XY));
|
||||
let ((u0, v0), (u1, v1)) = (locus_uv(ta), locus_uv(td));
|
||||
let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len;
|
||||
let mired = 1e6 / ta + along * (1e6 / td - 1e6 / ta);
|
||||
Some((1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K), tint_a + along * (tint_d - tint_a) + tint_per_len * off))
|
||||
}
|
||||
|
||||
/// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable
|
||||
/// multipliers.
|
||||
pub fn temp_tint(&self, wb_multipliers: [f32; 3]) -> Option<(f64, f64)> {
|
||||
@@ -142,6 +208,10 @@ impl KelvinPresets {
|
||||
return None;
|
||||
}
|
||||
let q = log_point(wb_multipliers);
|
||||
if let Some(pair) = self.pair {
|
||||
let (t, tint) = Self::pair_temp_tint(pair, q)?;
|
||||
return Some((t, tint.clamp(-MAX_TINT, MAX_TINT)));
|
||||
}
|
||||
let last = self.points.len().saturating_sub(2);
|
||||
let mut best: Option<(f64, f64, f64)> = None;
|
||||
for (i, w) in self.points.windows(2).enumerate() {
|
||||
@@ -290,9 +360,52 @@ mod tests {
|
||||
assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None);
|
||||
assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None);
|
||||
// a stored curve with a repeated point (only deserializing can make one) reads without dividing by zero
|
||||
let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3] };
|
||||
let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3], pair: None };
|
||||
assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None);
|
||||
assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None);
|
||||
assert_eq!(KelvinPresets { points: vec![], pair: None }.temp_tint(g(1400.0, 1400.0)), None);
|
||||
// and so does a stored neutral pair with equal endpoints
|
||||
let same = KelvinPresets { points: vec![], pair: Some([[1.0, 1.0, 1.0]; 2]) };
|
||||
assert_eq!(same.temp_tint(g(1400.0, 1400.0)), None);
|
||||
}
|
||||
|
||||
/// An X-T20's stored pair (`302 366 866 17 302 646 498 21`).
|
||||
fn xt20() -> (KelvinPresets, [f32; 3], [f32; 3]) {
|
||||
let (a, d) = ([366.0 / 302.0, 1.0, 866.0 / 302.0], [646.0 / 302.0, 1.0, 498.0 / 302.0]);
|
||||
(KelvinPresets::from_neutral_pair(a, d).unwrap(), a, d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_pair_reads_back_as_its_own_illuminants() {
|
||||
let (p, a, d) = xt20();
|
||||
let (ta, tint_a) = p.temp_tint(a).unwrap();
|
||||
let (td, tint_d) = p.temp_tint(d).unwrap();
|
||||
let (want_t, want_tint) = cct::xy_to_temp_tint(D65_XY);
|
||||
assert!((ta - cct::xy_to_temp_tint(A_XY).0).abs() < 2.0 && (tint_a - cct::xy_to_temp_tint(A_XY).1).abs() < 0.5, "{ta} {tint_a}");
|
||||
assert!((td - want_t).abs() < 2.0 && (tint_d - want_tint).abs() < 0.5, "{td} {tint_d}");
|
||||
// the points are the same reading at their temperatures, on the locus
|
||||
for (k, m) in p.points.clone() {
|
||||
let (t, tint) = p.temp_tint(m).unwrap();
|
||||
assert!((t - k as f64).abs() < 2.0 && tint.abs() < 12.0, "{k}: {t} {tint}");
|
||||
}
|
||||
// greener light than the daylight pair: positive tint, as for a matrix camera
|
||||
let (_, green) = p.temp_tint([d[0] * 1.06, 1.0, d[2] * 1.06]).unwrap();
|
||||
let (_, magenta) = p.temp_tint([d[0] / 1.06, 1.0, d[2] / 1.06]).unwrap();
|
||||
assert!(green > magenta + 15.0, "{green} {magenta}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_pairs_give_nothing() {
|
||||
assert_eq!(KelvinPresets::from_neutral_pair([0.0, 1.0, 1.0], [2.0, 1.0, 1.5]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fujifilm_x_trans_iii_models_are_covered() {
|
||||
for c in ["FUJIFILM X-T20", "Fujifilm X-T2", "FUJIFILM X100F", "FUJIFILM X-H1", "FUJIFILM X-Pro2", "FUJIFILM X-E3"] {
|
||||
assert!(covers_camera(c), "{c}");
|
||||
}
|
||||
for c in ["FUJIFILM X-T3", "FUJIFILM X-T30", "SONY X-T20", "FUJIFILM GFX 100"] {
|
||||
assert!(!covers_camera(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,12 +16,13 @@ pub enum PreviewColorSpace {
|
||||
|
||||
/// The colour space the raw says its embedded JPEG is in, for a JPEG that declares none itself (no ICC profile,
|
||||
/// no EXIF), first available first: the Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB, per Nikon
|
||||
/// tag documentation); the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the
|
||||
/// Interoperability IFD's InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB
|
||||
/// writes, issue #778; per the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour
|
||||
/// tag documentation); for a Canon CR2 or CR3, the Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB);
|
||||
/// the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the Interoperability IFD's
|
||||
/// InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB writes, issue #778; per
|
||||
/// the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour
|
||||
/// declaration takes precedence.
|
||||
pub fn embedded_preview_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
|
||||
let tiff = Tiff::parse(bytes).ok()?;
|
||||
let Ok(tiff) = Tiff::parse(bytes) else { return cr3_color_space(bytes) };
|
||||
let make = tiff.ifds.first()?.string(t::MAKE).unwrap_or_default();
|
||||
let exif = tiff.exif()?;
|
||||
if make.to_ascii_uppercase().starts_with("NIKON")
|
||||
@@ -33,6 +34,13 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
|
||||
if tiff.ifds.first()?.contains(t::DNG_VERSION) {
|
||||
return None;
|
||||
}
|
||||
if make.to_ascii_uppercase().starts_with("CANON")
|
||||
&& let Some(e) = exif.get(t::MAKER_NOTE)
|
||||
&& let Some(mn) = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)
|
||||
&& let Some(space) = canon_color_space(&mn.ifd)
|
||||
{
|
||||
return Some(space);
|
||||
}
|
||||
match exif.u16(0xA001)? {
|
||||
1 => Some(PreviewColorSpace::Srgb),
|
||||
0xFFFF if exif.interop.as_deref()?.bytes(0x0001)?.starts_with(b"R03") => Some(PreviewColorSpace::AdobeRgb),
|
||||
@@ -40,6 +48,29 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CR3 keeps its EXIF and maker note in `CMT2`/`CMT3` TIFF blocks and its JPEGs carry no EXIF; a body shot in
|
||||
/// Adobe RGB writes ColorSpace Uncalibrated (0xFFFF) with no Interoperability IFD (issue #2654), so the Canon
|
||||
/// maker note says which, else an EXIF ColorSpace of sRGB is taken.
|
||||
fn cr3_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
|
||||
let cr3 = lightcraft_meta::cr3::parse_cr3(bytes)?;
|
||||
let first = |i: usize| cr3.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next());
|
||||
if let Some(space) = first(2).and_then(|mn| canon_color_space(&mn)) {
|
||||
return Some(space);
|
||||
}
|
||||
(first(1)?.u16(0xA001)? == 1).then_some(PreviewColorSpace::Srgb)
|
||||
}
|
||||
|
||||
/// The Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB, per ExifTool's published Canon tag names).
|
||||
/// A Canon body shot in Adobe RGB writes EXIF ColorSpace Uncalibrated (0xFFFF) with InteropIndex "R98" in a CR2
|
||||
/// and no Interoperability IFD in a CR3, so the EXIF alone does not say Adobe RGB (issue #2654).
|
||||
fn canon_color_space(note: &Ifd) -> Option<PreviewColorSpace> {
|
||||
match note.u16(0x00b4)? {
|
||||
1 => Some(PreviewColorSpace::Srgb),
|
||||
2 => Some(PreviewColorSpace::AdobeRgb),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn nikon_color_space(bytes: &[u8], tiff: &Tiff, exif: &Ifd, make: &str) -> Option<PreviewColorSpace> {
|
||||
let e = exif.get(t::MAKER_NOTE)?;
|
||||
let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, make)?;
|
||||
|
||||
@@ -282,6 +282,72 @@ fn cr3_prefers_the_full_size_jpeg_track() {
|
||||
assert_eq!(embedded_preview(&file).unwrap(), small);
|
||||
}
|
||||
|
||||
/// A minimal CR3 whose Canon box holds `CMT2` (EXIF ColorSpace `exif`) and `CMT3` (maker-note ColorSpace `note`).
|
||||
fn cr3_with_color_space(exif: Option<u16>, note: Option<u16>) -> Vec<u8> {
|
||||
let bx = |kind: &[u8; 4], body: &[u8]| -> Vec<u8> { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() };
|
||||
let block = |tag: u16, v: Option<u16>| {
|
||||
let ifd =
|
||||
v.map_or_else(|| IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![100])), |v| IfdBuilder::new().with(tag, Value::Short(vec![v])));
|
||||
TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[ifd]).unwrap()
|
||||
};
|
||||
let cmt1 =
|
||||
TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("Canon".into()))]).unwrap();
|
||||
let canon_uuid = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
|
||||
let boxes = [bx(b"CMT1", &cmt1), bx(b"CMT2", &block(0xA001, exif)), bx(b"CMT3", &block(0x00b4, note))].concat();
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &bx(b"uuid", &[&canon_uuid[..], &boxes].concat())));
|
||||
file
|
||||
}
|
||||
|
||||
/// A CR3 (not TIFF-based) says its preview's colour space in the Canon maker note (`CMT3` 0x00b4), else an
|
||||
/// EXIF ColorSpace of sRGB in `CMT2`: an EOS R shot in Adobe RGB writes Uncalibrated with no interop (issue #2654).
|
||||
#[test]
|
||||
fn cr3_preview_colour_space_comes_from_the_canon_maker_note() {
|
||||
use PreviewColorSpace::{AdobeRgb, Srgb};
|
||||
let space = |exif, note| embedded_preview_color_space(&cr3_with_color_space(exif, note));
|
||||
assert_eq!(space(Some(0xFFFF), Some(2)), Some(AdobeRgb));
|
||||
assert_eq!(space(Some(1), Some(1)), Some(Srgb));
|
||||
assert_eq!(space(Some(1), Some(2)), Some(AdobeRgb), "the maker note wins");
|
||||
assert_eq!(space(Some(1), None), Some(Srgb), "no maker-note value: the EXIF sRGB");
|
||||
assert_eq!(space(Some(0xFFFF), Some(0xFFFF)), None, "neither says");
|
||||
assert_eq!(space(Some(0xFFFF), None), None);
|
||||
assert_eq!(space(None, None), None);
|
||||
let f = cr3_with_color_space(Some(0xFFFF), Some(2));
|
||||
for n in 0..f.len() {
|
||||
let _ = embedded_preview_color_space(&f[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A CR2 shot in Adobe RGB writes EXIF ColorSpace Uncalibrated with InteropIndex "R98", not "R03": the Canon
|
||||
/// maker note (0x00b4) says Adobe RGB (issue #2654). Another make's note is not read for it.
|
||||
#[test]
|
||||
fn cr2_preview_colour_space_comes_from_the_canon_maker_note() {
|
||||
use PreviewColorSpace::{AdobeRgb, Srgb};
|
||||
let cr2 = |make: &str, exif_space: u16, note: Option<u16>| {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(0xA001, Value::Short(vec![exif_space]));
|
||||
exif.set_child(t::INTEROP_IFD, IfdBuilder::new().with(0x0001, Value::Undefined(b"R98".to_vec())));
|
||||
if let Some(v) = note {
|
||||
// a plain one-entry IFD, its SHORT value inline
|
||||
let mut mn = 1u16.to_le_bytes().to_vec();
|
||||
mn.extend([0x00b4u16.to_le_bytes(), 3u16.to_le_bytes()].concat());
|
||||
mn.extend(1u32.to_le_bytes());
|
||||
mn.extend([v.to_le_bytes(), [0, 0]].concat());
|
||||
mn.extend(0u32.to_le_bytes());
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(mn));
|
||||
}
|
||||
let mut ifd0 = IfdBuilder::new().with(t::MAKE, Value::Ascii(make.into()));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
};
|
||||
assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, Some(2))), Some(AdobeRgb));
|
||||
assert_eq!(embedded_preview_color_space(&cr2("Canon", 1, Some(1))), Some(Srgb));
|
||||
assert_eq!(embedded_preview_color_space(&cr2("Canon", 1, None)), Some(Srgb), "no maker note: the EXIF");
|
||||
assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, None)), None, "R98 is not Adobe RGB's R03");
|
||||
assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, Some(0xFFFF))), None);
|
||||
assert_eq!(embedded_preview_color_space(&cr2("Leica", 0xFFFF, Some(2))), None, "only a Canon note");
|
||||
}
|
||||
|
||||
/// 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];
|
||||
|
||||
Vendored
+15
-1
@@ -118,6 +118,19 @@ pub(crate) fn unpack_row(src: &[u8], bits: u32, words32: bool, out: &mut [u16])
|
||||
}
|
||||
}
|
||||
|
||||
/// The Kelvin scale of a body without colour matrices, from tag `0xf00c` (`WB_GRBLevelsStandard`; tag number from
|
||||
/// `exiftool -v3` run as a black-box tool on the files): `G R B 17 G R B 21`, the camera's own neutral multipliers
|
||||
/// for Illuminant A and D65 (see [`crate::kelvin_presets::KelvinPresets::from_neutral_pair`]).
|
||||
fn fuji_kelvin_presets(ifd: &Ifd) -> Option<crate::kelvin_presets::KelvinPresets> {
|
||||
let v = ifd.f64s(0xf00c).filter(|v| v.len() >= 8)?;
|
||||
let grb = |at: usize, code: f64| {
|
||||
(v[at + 3] == code && v[at] > 0.0 && v[at + 1] > 0.0 && v[at + 2] > 0.0)
|
||||
.then(|| [(v[at + 1] / v[at]) as f32, 1.0, (v[at + 2] / v[at]) as f32])
|
||||
};
|
||||
let (a, d65) = (grb(0, 17.0)?, grb(4, 21.0)?);
|
||||
crate::kelvin_presets::KelvinPresets::from_neutral_pair(a, d65)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let h = header(bytes)?;
|
||||
let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?;
|
||||
@@ -191,6 +204,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let wb =
|
||||
ifd.f64s(0xf00e).filter(|v| v.len() >= 3 && v.iter().take(3).all(|x| *x > 0.0)).map(|v| [(v[1] / v[0]) as f32, 1.0, (v[2] / v[0]) as f32]);
|
||||
let white = white_from_data(&data, bits);
|
||||
let kelvin_presets = fuji_kelvin_presets(&ifd);
|
||||
|
||||
let mut metadata = h.jpeg.map(lightcraft_meta::extract).unwrap_or_default();
|
||||
metadata.width = Some(active.width as u32);
|
||||
@@ -209,7 +223,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
active_area: active,
|
||||
crop: Rect::new(0, 0, active.width, active.height),
|
||||
orientation,
|
||||
color: ColorData::default(),
|
||||
color: ColorData { kelvin_presets, ..Default::default() },
|
||||
wb_multipliers: wb,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"Couldn't open the editor: {e}": "Editor se nepodařilo otevřít: {e}",
|
||||
"Create Keyword Tag Inside “{name}”…": "Vytvořit značku klíčového slova uvnitř „{name}“…",
|
||||
"Create {n} Virtual Copies": "Vytvořit virtuální kopie: {n}",
|
||||
"Creates {} stacks from {} of {scope}": "Vytvoří {} sad z {} v {scope}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Vytvoří sady: {stacks} z fotografií: {photos} ({scope}){:.0}",
|
||||
"Decoder: {reason}": "Dekodér: {reason}",
|
||||
"Delete \"{label}\"": "Odstranit „{label}“",
|
||||
"Delete \"{label}\" (and the mask)": "Odstranit „{label}“ (i masku)",
|
||||
@@ -79,6 +79,7 @@
|
||||
"Healed {n} dust spot{}": "Retušované prachové skvrny: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Import byl zrušen · přidáno fotografií: {}{:.0}",
|
||||
"Import failed: {e}": "Import selhal: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… ({}) nebo je přetáhněte sem",
|
||||
"Import warnings ({n}):\n{warnings}": "Varování importu ({n}):\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "Importovaná přednastavení bodové křivky: {n}{:.0}",
|
||||
"Imported {n} preset{}": "Importovaná přednastavení: {n}{:.0}",
|
||||
@@ -111,6 +112,7 @@
|
||||
"Moved; {} photo(s) relinked": "Přesunuto; znovu propojeno fotografií: {}",
|
||||
"Named ({named_n})": "Pojmenované ({named_n})",
|
||||
"New Folder in “{name}”": "Nová složka v „{name}“",
|
||||
"Created folder “{name}” in “{parent}”": "Vytvořena složka „{name}“ v „{parent}“",
|
||||
"On {have} of {of} selected photos": "Na {have} z {of} vybraných fotografií",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "Nanášení „{k}“: kliknutím na fotografie přidáte nebo odeberete slovo · Esc ukončí režim",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "Vložit do fotografií: {n}{:.0} — vloží se pouze zkopírovaná nastavení",
|
||||
@@ -140,6 +142,7 @@
|
||||
"Rendering on the CPU: {why}": "Vykreslování na CPU: {why}",
|
||||
"Replace {} with:": "Nahradit {} za:",
|
||||
"Reset {name}": "Obnovit {name}",
|
||||
"{name} value": "{name} – hodnota",
|
||||
"Reset {} Channel": "Obnovit kanál {}",
|
||||
"Restore {}": "Obnovit {}",
|
||||
"Restore {} Photo{}": "Obnovit fotografie: {}{:.0}",
|
||||
@@ -283,7 +286,7 @@
|
||||
"Skipped {problem}": "Přeskočeno: {problem}",
|
||||
"…and {more} more": "…a další: {more}",
|
||||
"Template preview: {name}": "Náhled šablony: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "Fotografie v zobrazení: {n} · snímky: {slides} · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Fotografií v zobrazení: {n} · snímků: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} pages": "Stránky: {n}",
|
||||
"{n} JPEG files": "Soubory JPEG: {n}",
|
||||
"{s} s of AV1 video": "Video AV1: {s} s",
|
||||
@@ -297,7 +300,7 @@
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Přidáno sloučení {what} v rozlišení {mp}: plné rozlišení {full} vyžaduje více paměti, než má tento počítač",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Přidáno sloučení {what} v rozlišení {mp}, což je nejvyšší možné rozlišení panoramatu (plné rozlišení je {full})",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Barevný prostor: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "Vybrané fotografie: {n} · stránky: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "Vybrané fotografie: {n} · stránek: {pages}{:.0}{:.0}",
|
||||
"Already in “{}”": "Již v „{}“",
|
||||
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"Add to Local": "Přidat do místních souborů",
|
||||
"Add to My Photos": "Přidat do mých fotografií",
|
||||
"Can't be moved here": "Sem nelze přesunout",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… nebo je přetáhněte sem",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Tento řádek zahrnuje více disků: přetáhněte položky na některou ze složek v něm",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Místní fotografie zatím nejsou v knihovně: nejprve použijte Přidat do mých fotografií a pak je přesuňte",
|
||||
"Add to My Photos First": "Nejprve přidat do mých fotografií",
|
||||
@@ -254,6 +255,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Doplnit polohu fotografií ze záznamu trasy",
|
||||
"Auto-Tag from Tracklog": "Doplnit polohu ze záznamu trasy",
|
||||
"Auto-Tag from Tracklog…": "Doplnit polohu ze záznamu trasy…",
|
||||
"Add Locations": "Doplnit polohy",
|
||||
"Automatic": "Automatické",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatický režim vykresluje detail podle velikosti zobrazení, nejvýše v rozlišení fotografie. Zvolením velikosti nastavíte limit: menší je rychlejší.",
|
||||
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatický režim používá grafickou kartu tam, kde je rychlejší než procesor: při prvním zpracování fotografie se na tomto počítači změří obě možnosti.",
|
||||
@@ -498,6 +500,9 @@
|
||||
"Cropped": "Oříznuto",
|
||||
"Cross Process": "Křížové vyvolání",
|
||||
"Culling": "Třídění",
|
||||
"Cull": "Roztřídit",
|
||||
"Create Stacks": "Vytvořit sady",
|
||||
"Change Time": "Změnit čas",
|
||||
"Curve": "Křivka",
|
||||
"Custom": "Vlastní",
|
||||
"Custom template…": "Vlastní šablona…",
|
||||
@@ -902,7 +907,6 @@
|
||||
"Import from Device": "Importovat ze zařízení",
|
||||
"Import from Folder…": "Importovat ze složky…",
|
||||
"Import new photos": "Importovat nové fotografie",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… nebo je přetáhněte sem",
|
||||
"Imported": "Importováno",
|
||||
"Imported Photos": "Importované fotografie",
|
||||
"Importing": "Importování",
|
||||
@@ -1898,6 +1902,7 @@
|
||||
"Table": "Tabulka",
|
||||
"Tags": "Značky",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Značky: viz Značky vedle šablony. Soubory se přejmenují na disku (s přidruženými XMP); existující názvy získají -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Tlačítko Značky vypíše zástupné symboly pro šablonu, například {date} nebo {seq}. Každý soubor se na disku přejmenuje spolu s doprovodným souborem XMP; obsazený název dostane příponu -1, -2…",
|
||||
"Take a snapshot": "Pořídit snímek stavu",
|
||||
"Targeted Adjustment": "Cílená úprava",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Cílená úprava: tažením nahoru/dolů po fotografii upravíte barvy v daném místě",
|
||||
@@ -2429,5 +2434,8 @@
|
||||
"Set up face recognition": "Nastavit rozpoznávání obličejů",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Rozpoznávání obličejů potřebuje model, aby mohlo navrhovat jména: jednorázové stažení v Nastavení.",
|
||||
"Turn on name suggestions": "Zapnout návrhy jmen",
|
||||
"Set up name suggestions…": "Nastavit návrhy jmen…"
|
||||
"Set up name suggestions…": "Nastavit návrhy jmen…",
|
||||
"Top bar": "Horní lišta",
|
||||
"Tool strip": "Pruh nástrojů",
|
||||
"Bottom bar": "Dolní lišta"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Farbraum: Rec. 2020 {curve} (HDR-AVIF)",
|
||||
"Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}",
|
||||
"Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen",
|
||||
"Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Erstellt {stacks} Stapel aus {photos} von {scope}{:.0}",
|
||||
"Decoder: {reason}": "Decoder: {reason}",
|
||||
"Delete \"{label}\"": "„{label}“ löschen",
|
||||
"Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)",
|
||||
@@ -63,6 +63,7 @@
|
||||
"Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt",
|
||||
"Import failed: {e}": "Import fehlgeschlagen: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importiere Fotos über Datei → Fotos importieren… ({}) oder lege sie hier ab",
|
||||
"Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}",
|
||||
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil „{profile}“ wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"Rendering on the CPU: {why}": "Rendering auf der CPU: {why}",
|
||||
"Replace {} with:": "{} ersetzen durch:",
|
||||
"Reset {name}": "{name} zurücksetzen",
|
||||
"{name} value": "{name}: Wert",
|
||||
"Reset {} Channel": "Kanal {} zurücksetzen",
|
||||
"Restore {}": "{} wiederherstellen",
|
||||
"Restore {} Photo{}": "{} Foto{} wiederherstellen",
|
||||
@@ -165,7 +167,6 @@
|
||||
"{n} photo{}": "{n} Foto{}",
|
||||
"{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
|
||||
"{n} photo{} now · updates automatically as photos change": "Derzeit {n} Foto{} · wird bei Änderungen an Fotos automatisch aktualisiert",
|
||||
"{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten",
|
||||
"{pid} (missing)": "{pid} (fehlt)",
|
||||
"{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
@@ -251,6 +252,7 @@
|
||||
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)",
|
||||
"New Folder in “{name}”": "Neuer Ordner in „{name}“",
|
||||
"Created folder “{name}” in “{parent}”": "Ordner „{name}“ in „{parent}“ erstellt",
|
||||
"Moved {} photo(s)": "{} Foto(s) verschoben",
|
||||
"not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt",
|
||||
@@ -285,7 +287,8 @@
|
||||
"Skipped {problem}": "Übersprungen: {problem}",
|
||||
"…and {more} more": "…und {more} weitere",
|
||||
"Template preview: {name}": "Vorlagenvorschau: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Dias · {time}",
|
||||
"{n} selected photo{} · {pages} page{}": "Ausgewählte Fotos: {n} · Seiten: {pages}{:.0}{:.0}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Fotos in der Ansicht: {n} · Folien: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} pages": "{n} Seiten",
|
||||
"{n} JPEG files": "{n} JPEG-Dateien",
|
||||
"{s} s of AV1 video": "{s} s AV1-Video",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"Add to Local": "Zu „Lokal“ hinzufügen",
|
||||
"Add to My Photos": "Zu „Meine Fotos“ hinzufügen",
|
||||
"Can't be moved here": "Hierher nicht verschiebbar",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importiere Fotos über Datei → Fotos importieren… oder lege sie hier ab",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben",
|
||||
"Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen",
|
||||
@@ -135,6 +136,7 @@
|
||||
"Auto-Tag from Tracklog": "Anhand eines GPS-Tracks automatisch verorten",
|
||||
"Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatisches Verorten anhand eines GPS-Tracks: GPS-Orte für deine Fotos aus einem GPX-Track, abgeglichen nach Aufnahmezeit.",
|
||||
"Auto-Tag from Tracklog…": "Anhand eines GPS-Tracks automatisch verorten…",
|
||||
"Add Locations": "Orte hinzufügen",
|
||||
"Automatic": "Automatisch",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatisch rendert die Detailansicht in der angezeigten Größe, bis zu den eigenen Pixeln des Fotos. Eine Größe wählen, um sie zu begrenzen: kleiner ist schneller.",
|
||||
"Automatically Write Changes into XMP": "Änderungen automatisch in XMP schreiben",
|
||||
@@ -359,6 +361,9 @@
|
||||
"Crop, Rotate, Geometry": "Zuschnitt, Drehung, Geometrie",
|
||||
"Cross Process": "Crossentwicklung",
|
||||
"Culling": "Fotoauswahl",
|
||||
"Cull": "Auswählen",
|
||||
"Create Stacks": "Stapel erstellen",
|
||||
"Change Time": "Zeit ändern",
|
||||
"Curve": "Kurve",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"Custom template…": "Benutzerdefinierte Vorlage…",
|
||||
@@ -709,7 +714,6 @@
|
||||
"Import as new": "Neu importieren",
|
||||
"Import from Device": "Von Gerät importieren",
|
||||
"Import from Folder…": "Aus Ordner importieren…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importiere Fotos über Datei → Fotos importieren… oder lege sie hier ab",
|
||||
"Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Vorgaben aus anderen Editoren importieren: XMP-Vorgaben, klassische `.lrtemplate`-Dateien, „DNG-Vorgaben“ aus mobilen Apps und `.zip`-Pakete.",
|
||||
"Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import → Kopieren prüft jede Kopie wie beim Verschieben (Issue #96): Jede Datei wird neu geschrieben, auf den Datenträger synchronisiert",
|
||||
"Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import → Verschieben: Fotos gehen mit derselben Ordnerstruktur und Umbenennung wie beim Kopieren in den Zielordner, etwa",
|
||||
@@ -1526,6 +1530,7 @@
|
||||
"Table": "Tabelle",
|
||||
"Tags": "Platzhalter",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Platzhalter: Siehe „Platzhalter“ neben der Vorlage. Dateien werden auf dem Datenträger umbenannt (mit ihren XMP-Begleitdateien); vorhandene Namen erhalten -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Die Schaltfläche „Platzhalter“ zeigt, was in der Vorlage stehen kann, z. B. {date} oder {seq}. Jede Datei wird auf dem Datenträger zusammen mit ihrer XMP-Begleitdatei umbenannt; ein bereits vergebener Name erhält -1, -2…",
|
||||
"Take a snapshot": "Schnappschuss erstellen",
|
||||
"Targeted Adjustment": "Gezielte Anpassung",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Gezielte Anpassung: Im Foto nach oben/unten ziehen, um die dortigen Farben anzupassen",
|
||||
@@ -3461,5 +3466,8 @@
|
||||
"Set up face recognition": "Gesichtserkennung einrichten",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Die Gesichtserkennung braucht ein Modell, um Namen vorzuschlagen: ein einmaliger Download in den Einstellungen.",
|
||||
"Turn on name suggestions": "Namensvorschläge einschalten",
|
||||
"Set up name suggestions…": "Namensvorschläge einrichten…"
|
||||
"Set up name suggestions…": "Namensvorschläge einrichten…",
|
||||
"Top bar": "Obere Leiste",
|
||||
"Tool strip": "Werkzeugleiste",
|
||||
"Bottom bar": "Untere Leiste"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Espacio de color: Rec. 2020 {curve} (AVIF HDR)",
|
||||
"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}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Crea {stacks} pila{} a partir de {photos} de {scope}",
|
||||
"Decoder: {reason}": "Decodificador: {reason}",
|
||||
"Delete \"{label}\"": "Eliminar \"{label}\"",
|
||||
"Delete \"{label}\" (and the mask)": "Eliminar \"{label}\" (y la máscara)",
|
||||
@@ -63,6 +63,7 @@
|
||||
"Healed {n} dust spot{}": "{n} mancha{0} de polvo corregida{0}",
|
||||
"Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}",
|
||||
"Import failed: {e}": "Error de importación: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importar fotos con Archivo → Importar fotos… ({}) o soltarlas aquí",
|
||||
"Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}",
|
||||
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "No se pudieron escribir los archivos XMP de {n} fotos (¿su carpeta está desconectada?). LightCraft lo sigue intentando.",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"Rendering on the CPU: {why}": "Renderizado en la CPU: {why}",
|
||||
"Replace {} with:": "Reemplazar {} por:",
|
||||
"Reset {name}": "Restablecer {name}",
|
||||
"{name} value": "Valor de {name}",
|
||||
"Reset {} Channel": "Restablecer canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{}",
|
||||
@@ -165,7 +167,7 @@
|
||||
"{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",
|
||||
"{n} selected photos · {pages} pages": "{n} fotos seleccionadas · {pages} páginas",
|
||||
"{n} selected photo{} · {pages} page{}": "{n} foto{0} seleccionada{0} · {pages} página{1}",
|
||||
"{pid} (missing)": "{pid} (falta)",
|
||||
"{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
@@ -251,6 +253,7 @@
|
||||
"{what} merge cancelled": "Combinación {what} cancelada",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)",
|
||||
"New Folder in “{name}”": "Nueva carpeta en «{name}»",
|
||||
"Created folder “{name}” in “{parent}”": "Carpeta «{name}» creada en «{parent}»",
|
||||
"Moved {} photo(s)": "{} foto(s) movida(s)",
|
||||
"not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple",
|
||||
@@ -285,7 +288,7 @@
|
||||
"Skipped {problem}": "Omitido: {problem}",
|
||||
"…and {more} more": "…y {more} más",
|
||||
"Template preview: {name}": "Vista previa de la plantilla: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "{n} fotos en la vista · {slides} diapositivas · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "{n} foto{0} en la vista · {slides} diapositiva{1} · {time}",
|
||||
"{n} pages": "{n} páginas",
|
||||
"{n} JPEG files": "{n} archivos JPEG",
|
||||
"{s} s of AV1 video": "{s} s de vídeo AV1",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"Add to Local": "Añadir a Local",
|
||||
"Add to My Photos": "Añadir a Mis fotos",
|
||||
"Can't be moved here": "No se pueden mover aquí",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importar fotos con Archivo → Importar fotos… o soltarlas aquí",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas",
|
||||
"Add to Selected Photos": "Añadir a las fotos seleccionadas",
|
||||
@@ -118,6 +119,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Etiquetar fotos automáticamente desde el registro de ruta",
|
||||
"Auto-Tag from Tracklog": "Etiquetar automáticamente desde el registro de ruta",
|
||||
"Auto-Tag from Tracklog…": "Etiquetar automáticamente desde el registro de ruta…",
|
||||
"Add Locations": "Añadir ubicaciones",
|
||||
"Automatic": "Automático",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automático muestra la vista Detalle al tamaño en que se ve, hasta los píxeles propios de la foto. Elegir un tamaño para limitarla: cuanto más pequeño, más rápido.",
|
||||
"Automatically Write Changes into XMP": "Escribir automáticamente los cambios en XMP",
|
||||
@@ -349,6 +351,9 @@
|
||||
"Crop, Rotate, Geometry": "Recorte, rotación, geometría",
|
||||
"Cross Process": "Proceso cruzado",
|
||||
"Culling": "Selección",
|
||||
"Cull": "Seleccionar",
|
||||
"Create Stacks": "Crear pilas",
|
||||
"Change Time": "Cambiar hora",
|
||||
"Curve": "Curva",
|
||||
"Custom": "Personalizado",
|
||||
"Custom template…": "Plantilla personalizada…",
|
||||
@@ -716,7 +721,6 @@
|
||||
"Import as new": "Importar como nuevas",
|
||||
"Import from Device": "Importar desde dispositivo",
|
||||
"Import from Folder…": "Importar desde carpeta…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importar fotos con Archivo → Importar fotos… o soltarlas aquí",
|
||||
"Imported": "Importadas",
|
||||
"Imported Photos": "Fotos importadas",
|
||||
"Importing": "Importando",
|
||||
@@ -1543,6 +1547,7 @@
|
||||
"Table": "Tabla",
|
||||
"Tags": "Etiquetas",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Etiquetas: ver Etiquetas junto a la plantilla. Los archivos cambian de nombre en el disco (con sus archivos XMP complementarios); los nombres existentes reciben -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "El botón Etiquetas muestra los marcadores que puedes usar en la plantilla, como {date} o {seq}. Cada archivo cambia de nombre en el disco junto con su archivo XMP; si el nombre ya existe, se le añade -1, -2…",
|
||||
"Take a snapshot": "Crear una instantánea",
|
||||
"Targeted Adjustment": "Ajuste selectivo",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Ajuste selectivo: arrastrar hacia arriba/abajo en la foto para ajustar los colores de ese punto",
|
||||
@@ -2381,5 +2386,8 @@
|
||||
"Set up face recognition": "Configurar el reconocimiento facial",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "El reconocimiento facial necesita un modelo para sugerir nombres: una descarga única, en Ajustes.",
|
||||
"Turn on name suggestions": "Activar sugerencias de nombres",
|
||||
"Set up name suggestions…": "Configurar sugerencias de nombres…"
|
||||
"Set up name suggestions…": "Configurar sugerencias de nombres…",
|
||||
"Top bar": "Barra superior",
|
||||
"Tool strip": "Barra de herramientas",
|
||||
"Bottom bar": "Barra inferior"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"Create Keyword Tag Inside “{name}”…": "Luo avainsanatunniste kohteen ”{name}” sisään…",
|
||||
"Create the keyword “{name}”": "Luo avainsana ”{name}”",
|
||||
"Create {n} Virtual Copies": "Luo {n} virtuaalikopiota",
|
||||
"Creates {} stacks from {} of {scope}": "Luo pinoja: {} · kuvia: {} ({scope})",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Luo pinoja: {stacks}, kuvia: {photos} ({scope}){:.0}",
|
||||
"Decoder: {reason}": "Purkaja: {reason}",
|
||||
"Delete \"{label}\"": "Poista ”{label}”",
|
||||
"Delete \"{label}\" (and the mask)": "Poista ”{label}” (ja maski)",
|
||||
@@ -95,6 +95,7 @@
|
||||
"Healed {n} dust spot{}": "Korjattuja pölyhiukkasia: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Tuonti peruttu · lisättyjä kuvia: {}{:.0}",
|
||||
"Import failed: {e}": "Tuonti epäonnistui: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Tuo kuvia valitsemalla Tiedosto → Tuo kuvia… ({}) tai pudota ne tähän",
|
||||
"Import warnings ({n}):\n{warnings}": "Tuontivaroitukset ({n}):\n{warnings}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "Tuotuja maskitehosteita: {e}{:.0} (Maskaus → Tehoste)",
|
||||
"Imported {name}": "{name} tuotu",
|
||||
@@ -137,6 +138,7 @@
|
||||
"Moved; {} photo(s) relinked": "Siirretty; yhdistetty uudelleen kuvia: {}",
|
||||
"Named ({named_n})": "Nimetyt ({named_n})",
|
||||
"New Folder in “{name}”": "Uusi kansio kohteessa ”{name}”",
|
||||
"Created folder “{name}” in “{parent}”": "Kansio ”{name}” luotu kohteeseen ”{parent}”",
|
||||
"On {have} of {of} selected photos": "{have} / {of} valitulla kuvalla",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "Maalataan ”{k}”: napsauta kuvia lisätäksesi tai poistaaksesi sen · Esc lopettaa",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "Liitä kuviin: {n}{:.0} — vain kopioidut asetukset liitetään",
|
||||
@@ -168,6 +170,7 @@
|
||||
"Rendering on the CPU: {why}": "Renderöidään suorittimella: {why}",
|
||||
"Replace {} with:": "Korvaa {} seuraavalla:",
|
||||
"Reset {name}": "Nollaa {name}",
|
||||
"{name} value": "{name}: arvo",
|
||||
"Reset {} Channel": "Nollaa kanava {}",
|
||||
"Restore {}": "Palauta {}",
|
||||
"Restore {} Photo{}": "Palauta kuvia: {}{:.0}",
|
||||
@@ -243,15 +246,15 @@
|
||||
"{n} originals opened in the other editor": "Toisessa muokkausohjelmassa avattuja alkuperäisiä: {n}",
|
||||
"{n} pages": "Sivuja: {n}",
|
||||
"{n} photos": "Kuvia: {n}",
|
||||
"{n} photos in view · {slides} slides · {time}": "Kuvia näkymässä: {n} · dioja: {slides} · {time}",
|
||||
"{n} photos in {} are hidden by the filters above": "Yllä olevat suodattimet piilottavat kuvia kohteessa {}: {n}",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Muokattavaksi avattuja kuvia: {n}; kukin pinotaan alkuperäisensä kanssa",
|
||||
"{n} photo{}": "Kuvia: {n}{:.0}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Kuvia näkymässä: {n} · dioja: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "Täsmääviä kuvia: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa",
|
||||
"{n} photo{} now · updates automatically as photos change": "Kuvia nyt: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa",
|
||||
"{n} preset{}": "Esiasetuksia: {n}{:.0}",
|
||||
"{n} raws can now be developed from their raw data": "Raw-kuvia, jotka voidaan nyt kehittää raakadatastaan: {n}",
|
||||
"{n} selected photos · {pages} pages": "Valittuja kuvia: {n} · sivuja: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "Valittuja kuvia: {n} · sivuja: {pages}{:.0}{:.0}",
|
||||
"{n} similar photo{} · View ▸ Clear Filters to see all": "Samankaltaisia kuvia: {n}{:.0} · Näytä → Tyhjennä suodattimet näyttääksesi kaikki",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "Käynnissä olevia tehtäviä: {n}: {names}. Lopettaminen pysäyttää ne.",
|
||||
"{n} {noun}": "{n} {noun}",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Add Catchlight": "Lisää silmänvalo",
|
||||
"Add Face Model…": "Lisää kasvomalli…",
|
||||
"Add Folder to Local": "Lisää kansio paikalliseen",
|
||||
"Add Locations": "Lisää sijainnit",
|
||||
"Add Mask": "Lisää maski",
|
||||
"Add Object Click": "Lisää kohdenapsautus",
|
||||
"Add Point Color Sample": "Lisää pistevärin näyte",
|
||||
@@ -269,6 +270,7 @@
|
||||
"Center": "Keskellä",
|
||||
"Change": "Muuta",
|
||||
"Change Component Mode": "Vaihda komponenttitilaa",
|
||||
"Change Time": "Muuta aikaa",
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Tiedostonimissä kiellettyjä merkkejä (/ \\ : * ? \" < > |) korvataan merkillä -; olemassa oleva nimi saa lopun -1, -2….",
|
||||
"Charcoal": "Hiili",
|
||||
"Check All": "Valitse kaikki",
|
||||
@@ -429,6 +431,7 @@
|
||||
"Create Smart Album from Filter…": "Luo älyalbumi suodattimesta…",
|
||||
"Create Smart Album…": "Luo älyalbumi…",
|
||||
"Create Stack": "Luo pino",
|
||||
"Create Stacks": "Luo pinot",
|
||||
"Create Version": "Luo versio",
|
||||
"Create Version from Step": "Luo versio vaiheesta",
|
||||
"Create Virtual Copy": "Luo virtuaalikopio",
|
||||
@@ -443,6 +446,7 @@
|
||||
"Crop, Rotate, Geometry": "Rajaus, kierto, geometria",
|
||||
"Cropped": "Rajattu",
|
||||
"Cross Process": "Ristiinkäsittely",
|
||||
"Cull": "Karsi",
|
||||
"Culling": "Karsinta",
|
||||
"Curve": "Käyrä",
|
||||
"Curve channel": "Käyrän kanava",
|
||||
@@ -2008,6 +2012,7 @@
|
||||
"That includes the photos in the folders inside it.": "Tämä sisältää myös sen sisällä olevien kansioiden kuvat.",
|
||||
"That key can't be used as a shortcut": "Tätä näppäintä ei voi käyttää pikanäppäimenä",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 -malli on asennettu: Kohde- ja Kuvaile-maskit ovat valmiita.",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Tunnisteet-painike näyttää mallin paikkamerkit, kuten {date} tai {seq}. Jokainen tiedosto nimetään levyllä uudelleen yhdessä XMP-sivutiedostonsa kanssa; jo käytetty nimi saa lopuksi -1, -2…",
|
||||
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Kohinanpoistomalli on asennettu ja käytössä: Tekoälykohinanpoiston Määrä-liukusäädin Tarkastelun alla toimii nyt",
|
||||
"The download didn't work:": "Lataus ei onnistunut:",
|
||||
"The ending screen, after the last photo.": "Lopetusnäyttö viimeisen kuvan jälkeen.",
|
||||
@@ -2348,5 +2353,8 @@
|
||||
"Set up face recognition": "Määritä kasvojentunnistus",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Kasvojentunnistus tarvitsee mallin nimien ehdottamiseen: kertaluonteinen lataus Asetuksissa.",
|
||||
"Turn on name suggestions": "Ota nimiehdotukset käyttöön",
|
||||
"Set up name suggestions…": "Määritä nimiehdotukset…"
|
||||
"Set up name suggestions…": "Määritä nimiehdotukset…",
|
||||
"Top bar": "Yläpalkki",
|
||||
"Tool strip": "Työkalupalkki",
|
||||
"Bottom bar": "Alapalkki"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Espace colorimétrique : Rec. 2020 {curve} (AVIF HDR)",
|
||||
"Couldn't open the editor: {e}": "Impossible d’ouvrir l’éditeur : {e}",
|
||||
"Create {n} Virtual Copies": "Créer {n} copies virtuelles",
|
||||
"Creates {} stacks from {} of {scope}": "Crée {} piles à partir de {} parmi {scope}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Crée {stacks} pile{} à partir de {photos} parmi {scope}",
|
||||
"Decoder: {reason}": "Décodeur : {reason}",
|
||||
"Delete \"{label}\"": "Supprimer « {label} »",
|
||||
"Delete \"{label}\" (and the mask)": "Supprimer « {label} » (et le masque)",
|
||||
@@ -63,6 +63,7 @@
|
||||
"Healed {n} dust spot{}": "{n} tache{} de poussière corrigée(s)",
|
||||
"Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)",
|
||||
"Import failed: {e}": "Échec de l’importation : {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importez des photos avec Fichier → Importer des photos… ({}) ou déposez-les ici",
|
||||
"Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "{n} préréglage{} de courbe à points importé(s)",
|
||||
"Imported {n} preset{}": "{n} préréglage{} importé(s)",
|
||||
@@ -117,6 +118,7 @@
|
||||
"Rendering on the CPU: {why}": "Rendu sur le processeur : {why}",
|
||||
"Replace {} with:": "Remplacer {} par :",
|
||||
"Reset {name}": "Réinitialiser {name}",
|
||||
"{name} value": "{name} : valeur",
|
||||
"Reset {} Channel": "Réinitialiser le canal {}",
|
||||
"Restore {}": "Restaurer {}",
|
||||
"Restore {} Photo{}": "Restaurer {} photo{}",
|
||||
@@ -165,7 +167,7 @@
|
||||
"{n} photo{}": "{n} photo{}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} photo{} correspondante(s) · mise à jour automatique lorsque les photos changent",
|
||||
"{n} photo{} now · updates automatically as photos change": "{n} photo{} actuellement · mise à jour automatique lorsque les photos changent",
|
||||
"{n} selected photos · {pages} pages": "{n} photos sélectionnées · {pages} pages",
|
||||
"{n} selected photo{} · {pages} page{}": "{n} photo{0} sélectionnée{0} · {pages} page{1}",
|
||||
"{pid} (missing)": "{pid} (manquant)",
|
||||
"{sel_n} selected · {counted}": "{sel_n} sélectionné(s) · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0} %",
|
||||
@@ -251,6 +253,7 @@
|
||||
"{what} merge cancelled": "Fusion {what} annulée",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)",
|
||||
"New Folder in “{name}”": "Nouveau dossier dans « {name} »",
|
||||
"Created folder “{name}” in “{parent}”": "Dossier « {name} » créé dans « {parent} »",
|
||||
"Moved {} photo(s)": "{} photo(s) déplacée(s)",
|
||||
"not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »",
|
||||
@@ -285,7 +288,7 @@
|
||||
"Skipped {problem}": "Ignoré : {problem}",
|
||||
"…and {more} more": "…et {more} de plus",
|
||||
"Template preview: {name}": "Aperçu du modèle : {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "{n} photos affichées · {slides} diapositives · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "{n} photo{0} affichée{0} · {slides} diapositive{1} · {time}",
|
||||
"{n} pages": "{n} pages",
|
||||
"{n} JPEG files": "{n} fichiers JPEG",
|
||||
"{s} s of AV1 video": "{s} s de vidéo AV1",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"Add to Local": "Ajouter à Local",
|
||||
"Add to My Photos": "Ajouter à Mes photos",
|
||||
"Can't be moved here": "Impossible de les déplacer ici",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importez des photos avec Fichier → Importer des photos… ou déposez-les ici",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les",
|
||||
"Add to Selected Photos": "Ajouter aux photos sélectionnées",
|
||||
@@ -117,6 +118,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Géolocaliser automatiquement les photos à partir d'une trace GPS",
|
||||
"Auto-Tag from Tracklog": "Géolocaliser automatiquement à partir d'une trace GPS",
|
||||
"Auto-Tag from Tracklog…": "Géolocaliser automatiquement à partir d'une trace GPS…",
|
||||
"Add Locations": "Ajouter les lieux",
|
||||
"Automatic": "Automatique",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatique calcule la vue Détail à la taille affichée, jusqu'aux pixels de la photo elle-même. Choisissez une taille pour la plafonner : plus petit, c'est plus rapide.",
|
||||
"Automatically Write Changes into XMP": "Écrire automatiquement les modifications dans XMP",
|
||||
@@ -332,6 +334,9 @@
|
||||
"Crop, Rotate, Geometry": "Recadrage, rotation, géométrie",
|
||||
"Cross Process": "Traitement croisé",
|
||||
"Culling": "Tri",
|
||||
"Cull": "Trier",
|
||||
"Create Stacks": "Créer les piles",
|
||||
"Change Time": "Changer l'heure",
|
||||
"Curve": "Courbe",
|
||||
"Custom": "Personnalisé",
|
||||
"Custom template…": "Modèle personnalisé…",
|
||||
@@ -674,7 +679,6 @@
|
||||
"Import as new": "Importer comme nouvelles",
|
||||
"Import from Device": "Importer depuis un appareil",
|
||||
"Import from Folder…": "Importer depuis un dossier…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importez des photos avec Fichier → Importer des photos… ou déposez-les ici",
|
||||
"Imported": "Importées",
|
||||
"Imported Photos": "Photos importées",
|
||||
"Importing": "Importation en cours",
|
||||
@@ -1458,6 +1462,7 @@
|
||||
"Table": "Tableau",
|
||||
"Tags": "Balises",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Balises : voir Balises à côté du modèle. Les fichiers sont renommés sur le disque (avec leurs fichiers annexes XMP) ; les noms existants reçoivent -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Le bouton Balises liste les éléments utilisables dans le modèle, comme {date} ou {seq}. Chaque fichier est renommé sur le disque avec son fichier annexe XMP ; un nom déjà pris reçoit -1, -2…",
|
||||
"Take a snapshot": "Créer un instantané",
|
||||
"Targeted Adjustment": "Réglage ciblé",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster les couleurs à cet endroit",
|
||||
@@ -3564,5 +3569,8 @@
|
||||
"you come back; un-zoom it and it goes back to the size it had before.": "vous revenez ; dézoomez-la et elle retrouve la taille qu'elle avait avant.",
|
||||
"yours (issues #610, #1066). They were ordinary `.jpg` files in the library's `thumbs` folder, which is usually": "vous appartenant (tickets #610, #1066). C'étaient de simples fichiers `.jpg` dans le dossier `thumbs` de la bibliothèque, qui se trouve généralement",
|
||||
"zoom, so it goes by the window's size, and when in doubt takes the window for a window of the size it has: it": "zoom ; LightCraft se fie donc à la taille de la fenêtre et, dans le doute, la prend pour une simple fenêtre de cette taille : elle",
|
||||
"⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-clic et la ligne passe progressivement des anciennes valeurs aux nouvelles. La ligne entière forme une seule étape d'annulation."
|
||||
"⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-clic et la ligne passe progressivement des anciennes valeurs aux nouvelles. La ligne entière forme une seule étape d'annulation.",
|
||||
"Top bar": "Barre supérieure",
|
||||
"Tool strip": "Barre d’outils",
|
||||
"Bottom bar": "Barre inférieure"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"Exporting {} of {total}": "Mengekspor {} dari {total}",
|
||||
"Filter bar — {active} active filter{}": "Bilah filter — {active} filter aktif{:.0}",
|
||||
"Healed {n} dust spot{}": "{n} bintik debu diperbaiki{:.0}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Impor foto melalui File → Impor foto… ({}), atau seret ke sini",
|
||||
"Imported {n} point curve preset{}": "{n} preset kurva titik diimpor{:.0}",
|
||||
"Imported {n} preset{}": "{n} preset{:.0} diimpor",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n} preset diimpor{0:.0}, {f} file tidak dapat dibaca{1:.0}",
|
||||
@@ -137,6 +138,7 @@
|
||||
"Label Set: {name}": "Set label: {name}",
|
||||
"Quick Develop: {name}": "Olah cepat: {name}",
|
||||
"Reset {name}": "Atur ulang {name}",
|
||||
"{name} value": "Nilai {name}",
|
||||
"Updated “{name}”": "“{name}” diperbarui",
|
||||
"{month}": "{month:02}",
|
||||
"{month} {year}": "{month:02}/{year}",
|
||||
@@ -254,6 +256,7 @@
|
||||
"{what} merge cancelled": "Penggabungan {what} dibatalkan",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} foto Lokal{:.0} dilewati: album hanya dapat berisi foto pustaka (tambahkan ke Foto saya terlebih dahulu)",
|
||||
"New Folder in “{name}”": "Folder baru di “{name}”",
|
||||
"Created folder “{name}” in “{parent}”": "Folder “{name}” dibuat di “{parent}”",
|
||||
"Moved {} photo(s)": "{} foto dipindahkan",
|
||||
"not added to Apple Photos: {e}": "tidak ditambahkan ke Apple Photos: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} ditambahkan ke album Apple Photos “{album}”",
|
||||
@@ -314,5 +317,8 @@
|
||||
"Panel layout could not be restored: {error}": "Tata letak panel tidak dapat dipulihkan: {error}",
|
||||
"Panel move was not applied: {error}": "Pemindahan panel tidak diterapkan: {error}",
|
||||
"{name} is installed": "{name} sudah terinstal",
|
||||
"{name} is installed and in use: face recognition is on": "{name} sudah terinstal dan digunakan: pengenalan wajah aktif"
|
||||
"{name} is installed and in use: face recognition is on": "{name} sudah terinstal dan digunakan: pengenalan wajah aktif",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Membuat {stacks} tumpukan dari {photos} foto ({scope}){:.0}",
|
||||
"{n} selected photo{} · {pages} page{}": "{n} foto dipilih · {pages} halaman{:.0}{:.0}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "{n} foto di tampilan · {slides} slide · {time}{:.0}{:.0}"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Add Catchlight": "Tambahkan catchlight",
|
||||
"Add Face Model…": "Tambahkan model wajah…",
|
||||
"Add Folder to Local": "Tambahkan folder ke Lokal",
|
||||
"Add Locations": "Tambahkan lokasi",
|
||||
"Add Mask": "Tambahkan masker",
|
||||
"Add Object Click": "Tambahkan titik objek",
|
||||
"Add Point Color Sample": "Tambahkan sampel warna titik",
|
||||
@@ -273,6 +274,7 @@
|
||||
"Center": "Tengah",
|
||||
"Change": "Ubah",
|
||||
"Change Component Mode": "Ubah mode komponen",
|
||||
"Change Time": "Ubah waktu",
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Karakter yang tidak boleh ada di nama file (/ \\ : * ? \" < > |) diganti dengan -; nama yang sudah ada diberi akhiran -1, -2….",
|
||||
"Charcoal": "Abu-abu arang",
|
||||
"Check All": "Centang semua",
|
||||
@@ -428,6 +430,7 @@
|
||||
"Create Smart Album from Filter…": "Buat album pintar dari filter…",
|
||||
"Create Smart Album…": "Buat album pintar…",
|
||||
"Create Stack": "Buat tumpukan",
|
||||
"Create Stacks": "Buat tumpukan",
|
||||
"Create Version": "Buat versi",
|
||||
"Create Version from Step": "Buat versi dari langkah ini",
|
||||
"Create Virtual Copy": "Buat salinan virtual",
|
||||
@@ -442,6 +445,7 @@
|
||||
"Crop, Rotate, Geometry": "Pangkas, putar, geometri",
|
||||
"Cropped": "Dipangkas",
|
||||
"Cross Process": "Cross Process",
|
||||
"Cull": "Seleksi",
|
||||
"Culling": "Seleksi foto",
|
||||
"Curve": "Kurva",
|
||||
"Custom": "Kustom",
|
||||
@@ -2008,6 +2012,7 @@
|
||||
"That includes the photos in the folders inside it.": "Termasuk foto di dalam subfoldernya.",
|
||||
"That key can't be used as a shortcut": "Tombol itu tidak dapat digunakan sebagai pintasan",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 sudah terinstal: masker Objek dan Deskripsi siap digunakan.",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Tombol Tag menampilkan placeholder untuk templat, seperti {date} atau {seq}. Setiap file diganti namanya di disk bersama file sidecar XMP-nya; nama yang sudah dipakai mendapat -1, -2…",
|
||||
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model pengurangan noise sudah terinstal dan digunakan: slider Kekuatan pengurangan noise AI di panel Detail kini dapat dipakai",
|
||||
"The download didn't work:": "Unduhan gagal:",
|
||||
"The ending screen, after the last photo.": "Layar penutup, setelah foto terakhir.",
|
||||
@@ -2362,5 +2367,8 @@
|
||||
"Set up face recognition": "Siapkan pengenalan wajah",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Pengenalan wajah memerlukan model untuk menyarankan nama: sekali unduh, di Pengaturan.",
|
||||
"Turn on name suggestions": "Aktifkan saran nama",
|
||||
"Set up name suggestions…": "Siapkan saran nama…"
|
||||
"Set up name suggestions…": "Siapkan saran nama…",
|
||||
"Top bar": "Bilah atas",
|
||||
"Tool strip": "Bilah alat",
|
||||
"Bottom bar": "Bilah bawah"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"Collapse {name}": "Comprimi {name}",
|
||||
"Couldn't open the editor: {e}": "Impossibile aprire l'editor: {e}",
|
||||
"Create {n} Virtual Copies": "Crea {n} copie virtuali",
|
||||
"Creates {} stacks from {} of {scope}": "Crea pile: {} (da {} di {scope})",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Crea pile: {stacks} (da {photos} di {scope}){:.0}",
|
||||
"Decoder: {reason}": "Decodificatore: {reason}",
|
||||
"Delete \"{label}\"": "Elimina \"{label}\"",
|
||||
"Delete \"{label}\" (and the mask)": "Elimina \"{label}\" (e la maschera)",
|
||||
@@ -62,6 +62,7 @@
|
||||
"Healed {n} dust spot{}": "Macchie di polvere corrette: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Importazione annullata · foto aggiunte: {}{:.0}",
|
||||
"Import failed: {e}": "Importazione non riuscita: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importa le foto con File → Importa foto… ({}), oppure trascinale qui",
|
||||
"Import warnings ({n}):\n{warnings}": "Avvisi di importazione ({n}):\n{warnings}",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Il profilo XMP \"{profile}\" non è stato applicato a {path}; i colori possono differire da Lightroom.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometria di Lightroom non è stata conservata per {path}; {mode} verrà ricalcolato.",
|
||||
@@ -115,6 +116,7 @@
|
||||
"Rendering on the CPU: {why}": "Rendering sulla CPU: {why}",
|
||||
"Replace {} with:": "Sostituisci {} con:",
|
||||
"Reset {name}": "Reimposta {name}",
|
||||
"{name} value": "Valore di {name}",
|
||||
"Reset {} Channel": "Reimposta canale {}",
|
||||
"Restore {}": "Ripristina {}",
|
||||
"Restore {} Photo{}": "Ripristina {} foto{:.0}",
|
||||
@@ -243,6 +245,7 @@
|
||||
"{what} merge cancelled": "Unione {what} annullata",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Foto locali saltate: {n}{0:.0}; gli album contengono foto della libreria (prima aggiungile a Le mie foto)",
|
||||
"New Folder in “{name}”": "Nuova cartella in “{name}”",
|
||||
"Created folder “{name}” in “{parent}”": "Cartella “{name}” creata in “{parent}”",
|
||||
"Moved {} photo(s)": "Foto spostate: {}",
|
||||
"not added to Apple Photos: {e}": "non aggiunte a Foto di Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}",
|
||||
@@ -289,7 +292,7 @@
|
||||
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
|
||||
"Inside: {names}": "Al suo interno: {names}",
|
||||
"Template preview: {name}": "Anteprima del modello: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}{:.0}{:.0}",
|
||||
"Applied {name}": "{name} applicato",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” è già una persona. Unirle ne crea una sola; Annulla le separa di nuovo.",
|
||||
"{n} originals opened in the other editor": "Originali aperti nell'altro editor: {n}",
|
||||
@@ -297,7 +300,7 @@
|
||||
"Effect: {name}": "Effetto: {name}",
|
||||
" in {g} group{}": " in gruppi: {g}{:.0}",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Spazio colore: Rec. 2020 {curve} (AVIF HDR)",
|
||||
"{n} selected photos · {pages} pages": "Foto selezionate: {n} · pagine: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "Foto selezionate: {n} · pagine: {pages}{:.0}{:.0}",
|
||||
"Already in “{}”": "Già in “{}”",
|
||||
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"Add to Local": "Aggiungi a Locale",
|
||||
"Add to My Photos": "Aggiungi a Le mie foto",
|
||||
"Can't be moved here": "Non si possono spostare qui",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importa le foto con File → Importa foto…, oppure trascinale qui",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Questa riga comprende più di un disco: rilascia su una cartella al suo interno",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Le foto locali non sono ancora nella libreria: prima Aggiungi a Le mie foto, poi spostale",
|
||||
"Add to Selected Photos": "Aggiungi alle foto selezionate",
|
||||
@@ -118,6 +119,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Geolocalizza automaticamente le foto dal tracciato",
|
||||
"Auto-Tag from Tracklog": "Geolocalizza dal tracciato",
|
||||
"Auto-Tag from Tracklog…": "Geolocalizza dal tracciato…",
|
||||
"Add Locations": "Aggiungi posizioni",
|
||||
"Automatic": "Automatico",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatico disegna la vista Dettaglio alla dimensione in cui è mostrata, fino ai pixel della foto. Scegli una dimensione per limitarla: più piccola è più veloce.",
|
||||
"Automatically Write Changes into XMP": "Scrivi automaticamente le modifiche nell'XMP",
|
||||
@@ -333,6 +335,9 @@
|
||||
"Crop, Rotate, Geometry": "Ritaglio, rotazione, geometria",
|
||||
"Cross Process": "Processo incrociato",
|
||||
"Culling": "Selezione",
|
||||
"Cull": "Seleziona",
|
||||
"Create Stacks": "Crea pile",
|
||||
"Change Time": "Cambia ora",
|
||||
"Curve": "Curva",
|
||||
"Custom": "Personalizzato",
|
||||
"Custom template…": "Modello personalizzato…",
|
||||
@@ -675,7 +680,6 @@
|
||||
"Import as new": "Importa come nuova",
|
||||
"Import from Device": "Importa da dispositivo",
|
||||
"Import from Folder…": "Importa da cartella…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importa le foto con File → Importa foto…, oppure trascinale qui",
|
||||
"Imported": "Importato",
|
||||
"Imported Photos": "Foto importate",
|
||||
"Importing": "Importazione in corso",
|
||||
@@ -1488,6 +1492,7 @@
|
||||
"Table": "Tabella",
|
||||
"Tags": "Tag",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tag: vedi Tag accanto al modello. I file vengono rinominati sul disco (con i loro XMP collaterali); ai nomi già esistenti si aggiunge -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Il pulsante Tag elenca i segnaposto per il modello, come {date} o {seq}. Ogni file viene rinominato sul disco insieme al suo file XMP collaterale; un nome già in uso riceve -1, -2…",
|
||||
"Take a snapshot": "Acquisisci istantanea",
|
||||
"Targeted Adjustment": "Regolazione mirata",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare i colori in quel punto",
|
||||
@@ -2547,5 +2552,8 @@
|
||||
"source": "origine",
|
||||
"The active photo's capture time can't be read": "Impossibile leggere l'ora di scatto della foto attiva",
|
||||
"The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.": "L'elenco dei luoghi comprende i centri con almeno 15.000 abitanti: per uno più piccolo, Città indica il centro più grande più vicino.",
|
||||
"Unit": "Unità"
|
||||
"Unit": "Unità",
|
||||
"Top bar": "Barra superiore",
|
||||
"Tool strip": "Barra degli strumenti",
|
||||
"Bottom bar": "Barra inferiore"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"Exporting {} of {total}": "書き出し中:{total}枚中{}枚",
|
||||
"Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}",
|
||||
"Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "「ファイル → 写真を読み込む…」({})から読み込むか、ここに写真をドロップしてください",
|
||||
"Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}",
|
||||
"Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}",
|
||||
@@ -101,7 +102,7 @@
|
||||
"{n} photo{}": "{n}枚{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新",
|
||||
"{n} selected photos · {pages} pages": "選択した写真 {n} 枚 · {pages} ページ",
|
||||
"{n} selected photo{} · {pages} page{}": "選択した写真 {n} 枚 · {pages} ページ{:.0}{:.0}",
|
||||
"{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{what} merge added": "{what}合成を追加しました",
|
||||
@@ -137,6 +138,7 @@
|
||||
"Label Set: {name}": "ラベルセット:{name}",
|
||||
"Quick Develop: {name}": "クイック現像: {name}",
|
||||
"Reset {name}": "{name}をリセット",
|
||||
"{name} value": "{name}の値",
|
||||
"Updated “{name}”": "「{name}」を更新しました",
|
||||
"{month}": "{month}月",
|
||||
"{month} {year}": "{year}年{month}月",
|
||||
@@ -158,7 +160,7 @@
|
||||
"Auto Import: added {} photo{}": "自動読み込み:{}枚の写真を追加しました{:.0}",
|
||||
"Building previews for {} photos…": "{}枚のプレビューを作成中…",
|
||||
"Couldn't open the editor: {e}": "エディターを開けませんでした:{e}",
|
||||
"Creates {} stacks from {} of {scope}": "{scope}の{1}枚から{0}個のスタックを作成します",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "{scope}の{photos}枚から{stacks}個のスタックを作成します{:.0}",
|
||||
"Done ({what}): {done} photo{plural}": "完了({what}):{done}枚{plural:.0}",
|
||||
"Focus {:.0}": "ピント {:.0}",
|
||||
"Folder “{}” (and its subfolders)": "フォルダ「{}」(サブフォルダを含む)",
|
||||
@@ -255,6 +257,7 @@
|
||||
"{what} merge cancelled": "{what}合成をキャンセルしました",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)",
|
||||
"New Folder in “{name}”": "「{name}」に新規フォルダ",
|
||||
"Created folder “{name}” in “{parent}”": "「{parent}」にフォルダ「{name}」を作成しました",
|
||||
"Moved {} photo(s)": "{} 枚の写真を移動しました",
|
||||
"not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました",
|
||||
@@ -289,7 +292,7 @@
|
||||
"Skipped {problem}": "スキップ: {problem}",
|
||||
"…and {more} more": "…ほか {more} 件",
|
||||
"Template preview: {name}": "テンプレートのプレビュー: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}{:.0}{:.0}",
|
||||
"{n} pages": "{n} ページ",
|
||||
"{n} JPEG files": "JPEG ファイル {n} 個",
|
||||
"{s} s of AV1 video": "AV1 ビデオ {s} 秒",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"Add to Local": "ローカルに追加",
|
||||
"Add to My Photos": "マイフォトに追加",
|
||||
"Can't be moved here": "ここには移動できません",
|
||||
"Import photos with File → Import Photos…, or drop them here": "「ファイル → 写真を読み込む…」から読み込むか、ここに写真をドロップしてください",
|
||||
"This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください",
|
||||
"Add to Selected Photos": "選択した写真に追加",
|
||||
@@ -120,6 +121,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "GPS軌跡から写真に位置情報を自動で付ける",
|
||||
"Auto-Tag from Tracklog": "GPS軌跡から位置情報を付ける",
|
||||
"Auto-Tag from Tracklog…": "GPS軌跡から位置情報を付ける…",
|
||||
"Add Locations": "位置情報を追加",
|
||||
"Automatic": "自動",
|
||||
"Automatically Write Changes into XMP": "変更をXMPに自動保存",
|
||||
"Automatically write changes into XMP sidecars": "変更をXMPサイドカーファイルに自動保存",
|
||||
@@ -324,6 +326,9 @@
|
||||
"Crop, Rotate, Geometry": "切り抜き・回転・ジオメトリ",
|
||||
"Cross Process": "クロスプロセス",
|
||||
"Culling": "写真の選別",
|
||||
"Cull": "選別",
|
||||
"Create Stacks": "スタックを作成",
|
||||
"Change Time": "時刻を変更",
|
||||
"Curve": "カーブ",
|
||||
"Custom": "カスタム",
|
||||
"Custom template…": "カスタムテンプレート…",
|
||||
@@ -837,7 +842,6 @@
|
||||
"Import Profiles & Presets…": "プロファイルとプリセットを読み込む…",
|
||||
"Import from Device": "デバイスから読み込む",
|
||||
"Import from Folder…": "フォルダから読み込む…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "「ファイル → 写真を読み込む…」から読み込むか、ここに写真をドロップしてください",
|
||||
"Imported Photos": "写真を読み込み",
|
||||
"Importing": "読み込み中",
|
||||
"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).": "読み込みでは写真を検索しますが、フォルダはローカルに追加されません。読み込まずに作業する場合は「ローカル → フォルダを参照…」を使ってください",
|
||||
@@ -1400,6 +1404,7 @@
|
||||
"Table": "表",
|
||||
"Tags": "タグ",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "テンプレート横の「タグ」を参照してください。ファイルとXMPの名前を変更し、同名の場合は-1、-2…を付けます",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "「タグ」ボタンで、{date} や {seq} などテンプレートに使えるタグの一覧を表示します。各ファイルは XMP ファイルとともにディスク上で名前が変更され、同名のものには -1、-2… が付きます",
|
||||
"Targeted Adjustment": "ターゲット調整",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "写真の上を上下にドラッグして、その部分の色を調整",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the curve there": "写真の上を上下にドラッグして、その部分のカーブを調整",
|
||||
@@ -2379,5 +2384,8 @@
|
||||
"Set up face recognition": "顔認識を設定",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "顔認識で名前の候補を表示するにはモデルが必要です。設定から一度だけダウンロードします。",
|
||||
"Turn on name suggestions": "名前の候補をオンにする",
|
||||
"Set up name suggestions…": "名前の候補を設定…"
|
||||
"Set up name suggestions…": "名前の候補を設定…",
|
||||
"Top bar": "上部バー",
|
||||
"Tool strip": "ツールバー",
|
||||
"Bottom bar": "下部バー"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"Create Keyword Tag Inside “{name}”…": "საკვანძო სიტყვის შექმნა „{name}“-ში…",
|
||||
"Create {n} Virtual Copies": "ვირტუალური ასლების შექმნა: {n}",
|
||||
"Creates {} stacks from {} of {scope}": "შეიქმნება დასტა: {} — {}-დან ({scope})",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "შეიქმნება დასტა: {stacks} — {photos}-დან ({scope}){:.0}",
|
||||
"Decoder: {reason}": "დეკოდერი: {reason}",
|
||||
"Delete \"{label}\"": "„{label}“-ის წაშლა",
|
||||
"Delete \"{label}\" (and the mask)": "„{label}“-ის წაშლა (ნიღაბთან ერთად)",
|
||||
@@ -79,6 +80,7 @@
|
||||
"Healed {n} dust spot{}": "აღდგენილია მტვრის ლაქა: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "იმპორტი გაუქმდა · დაემატა ფოტო: {}{:.0}",
|
||||
"Import failed: {e}": "იმპორტი ვერ მოხერხდა: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "ფოტოების იმპორტისთვის გამოიყენეთ ფაილი → ფოტოების იმპორტი… ({}), ან ჩააგდეთ ისინი აქ",
|
||||
"Import warnings ({n}):\n{warnings}": "იმპორტის გაფრთხილებები ({n}):\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "იმპორტირებულია წერტილოვანი მრუდის პრესეტი: {n}{:.0}",
|
||||
"Imported {n} preset{}": "იმპორტირებულია პრესეტი: {n}{:.0}",
|
||||
@@ -111,6 +113,7 @@
|
||||
"Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}",
|
||||
"Named ({named_n})": "სახელიანი ({named_n})",
|
||||
"New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში",
|
||||
"Created folder “{name}” in “{parent}”": "საქაღალდე „{name}“ შეიქმნა „{parent}“-ში",
|
||||
"On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“-ის მინიჭება: დაწკაპეთ ფოტოებზე მის დასამატებლად ან მოსახსნელად · Esc ასრულებს",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "ჩასმა ფოტოებში: {n}{:.0} — ჩაისმება მხოლოდ კოპირებული პარამეტრები",
|
||||
@@ -140,6 +143,7 @@
|
||||
"Rendering on the CPU: {why}": "რენდერი CPU-ზე: {why}",
|
||||
"Replace {} with:": "{}-ის ჩანაცვლება:",
|
||||
"Reset {name}": "ჩამოყრა: {name}",
|
||||
"{name} value": "{name}: მნიშვნელობა",
|
||||
"Reset {} Channel": "არხის ჩამოყრა: {}",
|
||||
"Restore {}": "აღდგენა: {}",
|
||||
"Restore {} Photo{}": "ფოტოს აღდგენა: {}{:.0}",
|
||||
@@ -288,6 +292,7 @@
|
||||
"{n} JPEG files": "JPEG ფაილი: {n}",
|
||||
"{n} pages": "გვერდი: {n}",
|
||||
"{n} photos in view · {slides} slides · {time}": "ფოტო ხედში: {n} · სლაიდი: {slides} · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "ფოტო ხედში: {n} · სლაიდი: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} preset{}": "პრესეტი: {n}{:.0}",
|
||||
"{n} raws can now be developed from their raw data": "RAW ფაილების ({n}) დამუშავება ახლა მათი RAW მონაცემებიდან შეიძლება",
|
||||
"{photos} catalog records processed; {collections} collections.": "დამუშავდა კატალოგის ჩანაწერი: {photos}; კოლექცია: {collections}.",
|
||||
@@ -298,6 +303,7 @@
|
||||
"…and {more} more": "…და კიდევ {more}",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "ფერთა სივრცე: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "მონიშნული ფოტო: {n} · გვერდი: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "არჩეული ფოტო: {n} · გვერდი: {pages}{:.0}{:.0}",
|
||||
"Already in “{}”": "უკვე „{}“-შია",
|
||||
"Move {n} photo{} to “{}”": "ფოტოს გადატანა „{1}“-ში: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "გადავიდეს „{1}“-ში ფოტო: {n}{0:.0}?",
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
"Auto-Tag from Tracklog": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან",
|
||||
"Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან: GPS მდებარეობები თქვენი ფოტოებისთვის GPX მარშრუტის ჟურნალიდან, გადაღების დროის მიხედვით.",
|
||||
"Auto-Tag from Tracklog…": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან…",
|
||||
"Add Locations": "მდებარეობების დამატება",
|
||||
"Automatic": "ავტომატური",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "ავტომატური რეჟიმი დეტალურ ხედს ასახავს იმ ზომით, რომლითაც ის ჩანს, ფოტოს საკუთარ პიქსელებამდე. ზღვრისთვის აირჩიეთ ზომა: პატარა უფრო სწრაფია.",
|
||||
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "ავტომატური რეჟიმი იყენებს ვიდეობარათს, როცა ის პროცესორზე სწრაფია: ორივეს დრო ამ კომპიუტერზე იზომება, როცა ფოტო პირველად მზადდება.",
|
||||
@@ -506,6 +507,9 @@
|
||||
"Cropped": "ჩამოჭრილი",
|
||||
"Cross Process": "ჯვარედინი დამუშავება",
|
||||
"Culling": "გადარჩევა",
|
||||
"Cull": "გადარჩევა",
|
||||
"Create Stacks": "დასტების შექმნა",
|
||||
"Change Time": "დროის შეცვლა",
|
||||
"Culling.": "გადარჩევა.",
|
||||
"Curve": "მრუდი",
|
||||
"Custom": "მორგებული",
|
||||
@@ -2074,6 +2078,7 @@
|
||||
"Table": "ცხრილი",
|
||||
"Tags": "ტეგები",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "ტეგები: იხილეთ „ტეგები“ შაბლონის გვერდით. ფაილებს სახელი დისკზე ეცვლება (მათ XMP თანმხლებ ფაილებთან ერთად); არსებულ სახელებს ემატება -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "ღილაკი „ტეგები“ აჩვენებს შაბლონში გამოსაყენებელ ჩანაცვლებებს, მაგალითად {date} ან {seq}. თითოეულ ფაილს სახელი დისკზე ეცვლება მის XMP თანმხლებ ფაილთან ერთად; დაკავებულ სახელს ემატება -1, -2…",
|
||||
"Take a snapshot": "მომენტალური ასლის შექმნა",
|
||||
"Targeted Adjustment": "მიზნობრივი კორექცია",
|
||||
"Targeted adjustment on: drag up or down on the photo · Esc to stop": "მიზნობრივი კორექცია ჩართულია: გადაათრიეთ ფოტოზე ზემოთ ან ქვემოთ · Esc — შეწყვეტა",
|
||||
@@ -3650,5 +3655,8 @@
|
||||
"Set up face recognition": "სახეების ამოცნობის დაყენება",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "სახეების ამოცნობას სახელების შესათავაზებლად მოდელი სჭირდება: ერთჯერადი ჩამოტვირთვა, პარამეტრებში.",
|
||||
"Turn on name suggestions": "სახელების შეთავაზებების ჩართვა",
|
||||
"Set up name suggestions…": "სახელების შეთავაზებების დაყენება…"
|
||||
"Set up name suggestions…": "სახელების შეთავაზებების დაყენება…",
|
||||
"Top bar": "ზედა ზოლი",
|
||||
"Tool strip": "ხელსაწყოების ზოლი",
|
||||
"Bottom bar": "ქვედა ზოლი"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"Collapse {name}": "Zwiń {name}",
|
||||
"Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}",
|
||||
"Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych",
|
||||
"Creates {} stacks from {} of {scope}": "Tworzy {} stosów z {} w zakresie: {scope}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Tworzy stosy: {stacks} z {photos} w zakresie: {scope}{:.0}",
|
||||
"Decoder: {reason}": "Dekoder: {reason}",
|
||||
"Delete \"{label}\"": "Usuń „{label}”",
|
||||
"Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)",
|
||||
@@ -62,6 +62,7 @@
|
||||
"Healed {n} dust spot{}": "Usunięte plamki kurzu: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Import anulowany · dodane zdjęcia: {}{:.0}",
|
||||
"Import failed: {e}": "Import nieudany: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importuj zdjęcia przez Plik → Importuj zdjęcia… ({}) lub przeciągnij je tutaj",
|
||||
"Imported {n} point curve preset{}": "Zaimportowane ustawienia krzywej punktowej: {n}{:.0}",
|
||||
"Imported {n} preset{}": "Zaimportowane ustawienia wstępne: {n}{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Zaimportowane ustawienia wstępne: {n}{:.0}, nieczytelne pliki: {f}{:.0}",
|
||||
@@ -108,6 +109,7 @@
|
||||
"Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}",
|
||||
"Replace {} with:": "Zamień {} na:",
|
||||
"Reset {name}": "Wyzeruj {name}",
|
||||
"{name} value": "{name}: wartość",
|
||||
"Reset {} Channel": "Wyzeruj kanał {}",
|
||||
"Restore {}": "Przywróć {}",
|
||||
"Restore {} Photo{}": "Przywróć zdjęcia: {}{:.0}",
|
||||
@@ -239,6 +241,7 @@
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometria z Lightrooma dla {path} nie została zachowana; tryb {mode} zostanie obliczony ponownie.",
|
||||
"Moved {} photo(s)": "Przeniesione zdjęcia: {}",
|
||||
"New Folder in “{name}”": "Nowy folder w „{name}”",
|
||||
"Created folder “{name}” in “{parent}”": "Utworzono folder „{name}” w „{parent}”",
|
||||
"Restored {} photo{} from Recently Deleted": "Przywrócone z Ostatnio usuniętych zdjęcia: {}{:.0}",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "Korekty XMP dla {path} nie zostały zaimportowane: {fields}.",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP „{profile}” nie został zastosowany do {path}; kolory mogą się różnić od Lightrooma.",
|
||||
@@ -277,7 +280,7 @@
|
||||
"Skipped {problem}": "Pominięto: {problem}",
|
||||
"…and {more} more": "…i jeszcze {more}",
|
||||
"Template preview: {name}": "Podgląd szablonu: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} pages": "Strony: {n}",
|
||||
"{n} JPEG files": "Pliki JPEG: {n}",
|
||||
"{s} s of AV1 video": "Wideo AV1: {s} s",
|
||||
@@ -296,7 +299,7 @@
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano scalenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano scalenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar: {full})",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "Wybrane zdjęcia: {n} · stron: {pages}{:.0}{:.0}",
|
||||
"Already in “{}”": "Już w „{}”",
|
||||
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
"Auto-Tag from Tracklog": "Automatycznie oznacz na podstawie śladu GPS",
|
||||
"Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatyczne tagowanie ze śladu: przypisuje zdjęciom pozycje GPS ze śladu GPX na podstawie czasu wykonania.",
|
||||
"Auto-Tag from Tracklog…": "Automatycznie oznacz na podstawie śladu GPS…",
|
||||
"Add Locations": "Dodaj lokalizacje",
|
||||
"Automatic": "Automatycznie",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Tryb automatyczny renderuje widok szczegółów w wyświetlanym rozmiarze, nie przekraczając rozdzielczości zdjęcia. Wybierz mniejszy limit, aby przyspieszyć działanie.",
|
||||
"Automatically Write Changes into XMP": "Automatycznie zapisuj zmiany w XMP",
|
||||
@@ -321,6 +322,9 @@
|
||||
"Crop, Rotate, Geometry": "Kadrowanie, obrót, geometria",
|
||||
"Cross Process": "Obróbka krzyżowa",
|
||||
"Culling": "Selekcja zdjęć",
|
||||
"Cull": "Selekcjonuj",
|
||||
"Create Stacks": "Utwórz stosy",
|
||||
"Change Time": "Zmień czas",
|
||||
"Curve": "Krzywa",
|
||||
"Custom": "Niestandardowe",
|
||||
"Custom template…": "Szablon niestandardowy…",
|
||||
@@ -1954,6 +1958,7 @@
|
||||
"Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni RGB,",
|
||||
"TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Znaczniki: zobacz Znaczniki obok szablonu. Nazwy plików na dysku (wraz z plikami XMP) są zmieniane; kolizje otrzymują przyrostek -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Przycisk Tagi pokazuje symbole, których można użyć w szablonie, np. {date} lub {seq}. Nazwa każdego pliku na dysku jest zmieniana razem z jego plikiem XMP; zajęta nazwa dostaje przyrostek -1, -2…",
|
||||
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka",
|
||||
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON",
|
||||
"The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który",
|
||||
@@ -2306,5 +2311,8 @@
|
||||
"Set up face recognition": "Skonfiguruj rozpoznawanie twarzy",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Rozpoznawanie twarzy potrzebuje modelu, aby proponować imiona: jednorazowe pobranie w Ustawieniach.",
|
||||
"Turn on name suggestions": "Włącz propozycje imion",
|
||||
"Set up name suggestions…": "Skonfiguruj propozycje imion…"
|
||||
"Set up name suggestions…": "Skonfiguruj propozycje imion…",
|
||||
"Top bar": "Górny pasek",
|
||||
"Tool strip": "Pasek narzędzi",
|
||||
"Bottom bar": "Dolny pasek"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Filter bar — {active} active filter{}": "Barra de filtros — {active} filtro{0} ativo{0}",
|
||||
"Healed {n} dust spot{}": "{n} mancha{0} de poeira recuperada{0}",
|
||||
"Import cancelled · {} photo{} added": "Importação cancelada · {0} foto{1} adicionada{1}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importe fotos com Arquivo → Importar fotos… ({}) ou solte-as aqui",
|
||||
"Imported {n} point curve preset{}": "{n} predefinição(ões) de curva de pontos importada(s){:.0}",
|
||||
"Imported {n} preset{}": "{n} predefinição(ões) importada(s){:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n} predefinição(ões) importada(s){:.0}, {f} arquivo(s) não legível(is){:.0}",
|
||||
@@ -86,6 +87,7 @@
|
||||
"Rename “{name}”": "Renomear “{name}”",
|
||||
"Rendering on the CPU: {why}": "Renderizando na CPU: {why}",
|
||||
"Reset {name}": "Redefinir {name}",
|
||||
"{name} value": "Valor de {name}",
|
||||
"Reset {} Channel": "Redefinir canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{}",
|
||||
@@ -125,7 +127,7 @@
|
||||
"{n} photo{}": "{n} foto{}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} foto{0} correspondente{0} · atualiza automaticamente conforme as fotos mudam",
|
||||
"{n} photo{} now · updates automatically as photos change": "{n} foto{} agora · atualiza automaticamente conforme as fotos mudam",
|
||||
"{n} selected photos · {pages} pages": "{n} fotos selecionadas · {pages} páginas",
|
||||
"{n} selected photo{} · {pages} page{}": "{n} foto{0} selecionada{0} · {pages} página{1}",
|
||||
"{sel_n} selected · {counted}": "{sel_n} selecionadas · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{verb} {n} Photo{}": "{verb} {n} foto{}",
|
||||
@@ -158,7 +160,7 @@
|
||||
"Auto Import: added {} photo{}": "Importação automática: {} foto{1} adicionada{1}",
|
||||
"Building previews for {} photos…": "Gerando prévias de {} fotos…",
|
||||
"Couldn't open the editor: {e}": "Não foi possível abrir o editor: {e}",
|
||||
"Creates {} stacks from {} of {scope}": "Cria {} pilhas a partir de {} de {scope}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Cria {stacks} pilha{} a partir de {photos} de {scope}",
|
||||
"Done ({what}): {done} photo{plural}": "Concluído ({what}): {done} foto{plural}",
|
||||
"Focus {:.0}": "Foco {:.0}",
|
||||
"Folder “{}” (and its subfolders)": "Pasta “{}” (e suas subpastas)",
|
||||
@@ -255,6 +257,7 @@
|
||||
"{what} merge cancelled": "{what} mesclagem cancelada",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)",
|
||||
"New Folder in “{name}”": "Nova pasta em “{name}”",
|
||||
"Created folder “{name}” in “{parent}”": "Pasta “{name}” criada em “{parent}”",
|
||||
"Moved {} photo(s)": "{} foto(s) movida(s)",
|
||||
"not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple",
|
||||
@@ -289,7 +292,7 @@
|
||||
"Skipped {problem}": "Ignorado: {problem}",
|
||||
"…and {more} more": "…e mais {more}",
|
||||
"Template preview: {name}": "Prévia do modelo: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "{n} fotos na visualização · {slides} slides · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "{n} foto{0} na visualização · {slides} slide{1} · {time}",
|
||||
"{n} pages": "{n} páginas",
|
||||
"{n} JPEG files": "{n} arquivos JPEG",
|
||||
"{s} s of AV1 video": "{s} s de vídeo AV1",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"Add to Local": "Adicionar a Local",
|
||||
"Add to My Photos": "Adicionar às Minhas fotos",
|
||||
"Can't be moved here": "Não podem ser movidas para cá",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importe fotos com Arquivo → Importar fotos… ou solte-as aqui",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as",
|
||||
"Add to Selected Photos": "Adicionar às fotos selecionadas",
|
||||
@@ -123,6 +124,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Etiquetar fotos automaticamente pelo tracklog",
|
||||
"Auto-Tag from Tracklog": "Etiquetar automaticamente pelo tracklog",
|
||||
"Auto-Tag from Tracklog…": "Etiquetar automaticamente pelo tracklog…",
|
||||
"Add Locations": "Adicionar locais",
|
||||
"Automatic": "Automático",
|
||||
"Automatically Write Changes into XMP": "Gravar alterações automaticamente no XMP",
|
||||
"Automatically write changes into XMP sidecars": "Grava automaticamente as alterações nos arquivos XMP auxiliares",
|
||||
@@ -340,6 +342,9 @@
|
||||
"Crop, Rotate, Geometry": "Recorte, rotação, geometria",
|
||||
"Cross Process": "Processamento cruzado",
|
||||
"Culling": "Triagem",
|
||||
"Cull": "Fazer triagem",
|
||||
"Create Stacks": "Criar pilhas",
|
||||
"Change Time": "Alterar hora",
|
||||
"Curve": "Curva",
|
||||
"Custom": "Personalizado",
|
||||
"Custom template…": "Modelo personalizado…",
|
||||
@@ -683,7 +688,6 @@
|
||||
"Import as new": "Importar como novas",
|
||||
"Import from Device": "Importar de dispositivo",
|
||||
"Import from Folder…": "Importar de pasta…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importe fotos com Arquivo → Importar fotos… ou solte-as aqui",
|
||||
"Imported": "Importados",
|
||||
"Imported Photos": "Fotos importadas",
|
||||
"Importing": "Importando",
|
||||
@@ -1482,6 +1486,7 @@
|
||||
"Table": "Tabela",
|
||||
"Tags": "Marcadores",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Marcadores: consulte Marcadores ao lado do modelo. Os arquivos são renomeados no disco (com seus sidecars XMP); nomes existentes recebem -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "O botão Marcadores lista os itens que você pode usar no modelo, como {date} ou {seq}. Cada arquivo é renomeado no disco junto com seu sidecar XMP; um nome já usado recebe -1, -2…",
|
||||
"Take a snapshot": "Tirar um instantâneo",
|
||||
"Targeted Adjustment": "Ajuste direcionado",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Ajuste direcionado: arraste para cima/baixo na foto para ajustar as cores ali",
|
||||
@@ -2225,5 +2230,8 @@
|
||||
"Set up face recognition": "Configurar o reconhecimento facial",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "O reconhecimento facial precisa de um modelo para sugerir nomes: um download único, em Configurações.",
|
||||
"Turn on name suggestions": "Ativar sugestões de nomes",
|
||||
"Set up name suggestions…": "Configurar sugestões de nomes…"
|
||||
"Set up name suggestions…": "Configurar sugestões de nomes…",
|
||||
"Top bar": "Barra superior",
|
||||
"Tool strip": "Barra de ferramentas",
|
||||
"Bottom bar": "Barra inferior"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Цветовое пространство: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"Couldn't open the editor: {e}": "Не удалось открыть редактор: {e}",
|
||||
"Create {n} Virtual Copies": "Создать виртуальные копии: {n}",
|
||||
"Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Будет создано стопок: {stacks} из {photos} фото ({scope}){:.0}",
|
||||
"Decoder: {reason}": "Декодер: {reason}",
|
||||
"Delete \"{label}\"": "Удалить «{label}»",
|
||||
"Delete \"{label}\" (and the mask)": "Удалить «{label}» (и маску)",
|
||||
@@ -66,6 +66,7 @@
|
||||
"Healed {n} dust spot{}": "Убрано пылинок: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}",
|
||||
"Import failed: {e}": "Ошибка импорта: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Импортируйте фото через Файл → Импорт фото… ({}) или перетащите их сюда",
|
||||
"Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}",
|
||||
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не удалось записать XMP-файлы {n} фото (папка недоступна?). LightCraft продолжает попытки.",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.",
|
||||
@@ -122,6 +123,7 @@
|
||||
"Rendering on the CPU: {why}": "Рендеринг на CPU: {why}",
|
||||
"Replace {} with:": "Заменить {} на:",
|
||||
"Reset {name}": "Сбросить: {name}",
|
||||
"{name} value": "{name}: значение",
|
||||
"Reset {} Channel": "Сбросить канал: {}",
|
||||
"Restore {}": "Восстановить {}",
|
||||
"Restore {} Photo{}": "Восстановить фото: {}{:.0}",
|
||||
@@ -170,7 +172,7 @@
|
||||
"{n} photo{}": "Фото: {n}{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "Подходящих фото: {n}{:.0} · обновляется автоматически при изменении фото",
|
||||
"{n} photo{} now · updates automatically as photos change": "Сейчас фото: {n}{:.0} · обновляется автоматически при изменении фото",
|
||||
"{n} selected photos · {pages} pages": "Выбрано фото: {n} · страниц: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "Выбрано фото: {n} · страниц: {pages}{:.0}{:.0}",
|
||||
"{n} similar photo{} · View ▸ Clear Filters to see all": "Похожих фото: {n}{:.0} · Вид → Сбросить фильтры, чтобы увидеть все",
|
||||
"{pid} (missing)": "{pid} (отсутствует)",
|
||||
"{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}",
|
||||
@@ -251,6 +253,7 @@
|
||||
"{what} merge cancelled": "{what}: слияние отменено",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)",
|
||||
"New Folder in “{name}”": "Новая папка в «{name}»",
|
||||
"Created folder “{name}” in “{parent}”": "Папка «{name}» создана в «{parent}»",
|
||||
"Moved {} photo(s)": "Перемещено фото: {}",
|
||||
"not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}",
|
||||
@@ -285,7 +288,7 @@
|
||||
"Skipped {problem}": "Пропущено: {problem}",
|
||||
"…and {more} more": "…и ещё {more}",
|
||||
"Template preview: {name}": "Просмотр шаблона: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} pages": "Страниц: {n}",
|
||||
"{n} JPEG files": "Файлов JPEG: {n}",
|
||||
"{s} s of AV1 video": "{s} с видео AV1",
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
"The layout image can't be shown": "Не удаётся показать изображение макета",
|
||||
"Their photos in the grid": "Их фото в сетке",
|
||||
"This page setup is not valid": "Эти параметры страницы недопустимы",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Импортируйте фото через Файл → Импорт фото… или перетащите их сюда",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте",
|
||||
"Add to Selected Photos": "Добавить к выбранным фото",
|
||||
@@ -288,6 +289,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Автопривязка фото по треку",
|
||||
"Auto-Tag from Tracklog": "Автопривязка по треку",
|
||||
"Auto-Tag from Tracklog…": "Автопривязка по треку…",
|
||||
"Add Locations": "Добавить места",
|
||||
"Automatic": "Автоматически",
|
||||
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Автоматический режим рендерит «Детализацию» в отображаемом размере, вплоть до собственных пикселей фото. Выберите размер, чтобы ограничить: меньше — быстрее.",
|
||||
"Automatically Write Changes into XMP": "Автоматически записывать изменения в XMP",
|
||||
@@ -478,6 +480,9 @@
|
||||
"Crop Aspect": "Пропорции кадра",
|
||||
"Cross Process": "Кросс-процесс",
|
||||
"Culling": "Отбор",
|
||||
"Cull": "Отобрать",
|
||||
"Create Stacks": "Создать стопки",
|
||||
"Change Time": "Изменить время",
|
||||
"Curve": "Кривая",
|
||||
"Custom": "Свои",
|
||||
"Custom…": "Свои…",
|
||||
@@ -759,7 +764,6 @@
|
||||
"Import Profiles & Presets…": "Импортировать профили и пресеты…",
|
||||
"Import from Device": "Импорт с устройства",
|
||||
"Import from Folder…": "Импорт из папки…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Импортируйте фото через Файл → Импорт фото… или перетащите их сюда",
|
||||
"Imported Photos": "Импортированные фото",
|
||||
"Importing": "Импорт",
|
||||
"Importing Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
@@ -1452,6 +1456,7 @@
|
||||
"System default": "Как в системе",
|
||||
"Tags": "Теги",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Теги: см. «Теги» рядом с шаблоном. Файлы переименовываются на диске (вместе с XMP-файлами); к существующим именам добавляется -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Кнопка «Теги» показывает, что можно вставить в шаблон, например {date} или {seq}. Каждый файл переименовывается на диске вместе с XMP-файлом; к занятому имени добавляется -1, -2…",
|
||||
"Take a snapshot": "Сделать снимок",
|
||||
"Targeted Adjustment": "Целевая коррекция",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Целевая коррекция: перетаскивайте вверх/вниз по фото, чтобы менять цвета в этом месте",
|
||||
@@ -2587,5 +2592,8 @@
|
||||
"Set up face recognition": "Настроить распознавание лиц",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Для подсказок имён распознаванию лиц нужна модель: однократная загрузка в Настройках.",
|
||||
"Turn on name suggestions": "Включить подсказки имён",
|
||||
"Set up name suggestions…": "Настроить подсказки имён…"
|
||||
"Set up name suggestions…": "Настроить подсказки имён…",
|
||||
"Top bar": "Верхняя панель",
|
||||
"Tool strip": "Панель инструментов",
|
||||
"Bottom bar": "Нижняя панель"
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"Healed {n} dust spot{}": "Retuscherade {n} dammfläck{}",
|
||||
"Import cancelled · {} photo{} added": "Importen avbröts · {} foto{} tillagda",
|
||||
"Import failed: {e}": "Importen misslyckades: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Importera foton med Arkiv → Importera foton… ({}) eller släpp dem här",
|
||||
"Import warnings ({n}):\n{warnings}": "Importvarningar ({n}):\n{warnings}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "Importerade {e} maskeffekt{} (Maskering → Effekt)",
|
||||
"Imported {name}": "Importerade {name}",
|
||||
@@ -130,6 +131,7 @@
|
||||
"Moved; {} photo(s) relinked": "Flyttat; {} foto(n) har länkats om",
|
||||
"Named ({named_n})": "Namngivna ({named_n})",
|
||||
"New Folder in “{name}”": "Ny mapp i ”{name}”",
|
||||
"Created folder “{name}” in “{parent}”": "Mappen ”{name}” skapad i ”{parent}”",
|
||||
"new, inside {parent}": "ny, inuti {parent}",
|
||||
"none (CPU rendering): {why}": "ingen (CPU-rendering): {why}",
|
||||
"not added to Apple Photos: {e}": "inte tillagt i Apple Photos: {e}",
|
||||
@@ -164,6 +166,7 @@
|
||||
"Rendering on the CPU: {why}": "Renderar på processorn: {why}",
|
||||
"Replace {} with:": "Ersätt {} med:",
|
||||
"Reset {name}": "Återställ {name}",
|
||||
"{name} value": "{name}: värde",
|
||||
"Reset {} Channel": "Återställ kanalen {}",
|
||||
"Restore {}": "Återställ {}",
|
||||
"Restore {} Photo{}": "Återställ {} foto{}",
|
||||
@@ -314,5 +317,8 @@
|
||||
"Panel layout could not be restored: {error}": "Panellayouten kunde inte återställas: {error}",
|
||||
"Panel move was not applied: {error}": "Panelflytten tillämpades inte: {error}",
|
||||
"{name} is installed": "{name} är installerad",
|
||||
"{name} is installed and in use: face recognition is on": "{name} är installerad och används: ansiktsigenkänning är på"
|
||||
"{name} is installed and in use: face recognition is on": "{name} är installerad och används: ansiktsigenkänning är på",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Skapar staplar: {stacks} · foton: {photos} av {scope}{:.0}",
|
||||
"{n} selected photo{} · {pages} page{}": "Markerade foton: {n} · sidor: {pages}{:.0}{:.0}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Foton i vyn: {n} · bilder: {slides} · {time}{:.0}{:.0}"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"4 MiB and connections at 16.": "4 MiB och anslutningar till 16.",
|
||||
"630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630-drivrutiner): i Windows använder fönstret och GPU-renderingen endast DirectX 12 och läser aldrig in Vulkan-drivrutinen om inte",
|
||||
"= exactly": "= exakt",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importera foton med Arkiv → Importera foton… eller släpp dem här",
|
||||
"`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; på skiftlägesokänsliga volymer går ändringen av skiftläge ändå igenom.",
|
||||
"`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) tillsammans med sina XMP-sidofiler; varje original lämnar kortet först efter att dess",
|
||||
"A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "En 16:9-bild med svarta fält ovanför och nedanför, titeln i hörnet, toningar via svart och ett avslutande kort.",
|
||||
@@ -963,7 +964,6 @@
|
||||
"Import Lightroom Catalog…": "Importera Lightroom-katalog…",
|
||||
"Import new photos": "Importera nya foton",
|
||||
"Import Photos": "Importera foton",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Importera foton med Arkiv → Importera foton… eller släpp dem här",
|
||||
"Import Photos…": "Importera foton…",
|
||||
"Import Point Curve Presets": "Importera förinställningar för punktkurva",
|
||||
"Import Point Curve Presets…": "Importera förinställningar för punktkurva…",
|
||||
@@ -2451,5 +2451,13 @@
|
||||
"Set up face recognition": "Ställ in ansiktsigenkänning",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Ansiktsigenkänning behöver en modell för att föreslå namn: en engångsnedladdning, i Inställningar.",
|
||||
"Turn on name suggestions": "Slå på namnförslag",
|
||||
"Set up name suggestions…": "Ställ in namnförslag…"
|
||||
"Set up name suggestions…": "Ställ in namnförslag…",
|
||||
"Change Time": "Ändra tid",
|
||||
"Cull": "Gallra",
|
||||
"Add Locations": "Lägg till platser",
|
||||
"Create Stacks": "Skapa staplar",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Knappen Taggar visar platshållare för mallen, till exempel {date} eller {seq}. Varje fil byter namn på disken tillsammans med sin XMP-sidofil; ett namn som redan finns får -1, -2…",
|
||||
"Top bar": "Övre fältet",
|
||||
"Tool strip": "Verktygsfält",
|
||||
"Bottom bar": "Nedre fältet"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Колірний простір: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"Couldn't open the editor: {e}": "Не вдалося відкрити редактор: {e}",
|
||||
"Create {n} Virtual Copies": "Створити віртуальні копії: {n}",
|
||||
"Creates {} stacks from {} of {scope}": "Буде створено стосів: {} із {} ({scope})",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Буде створено стосів: {stacks} із {photos} ({scope}){:.0}",
|
||||
"Decoder: {reason}": "Декодер: {reason}",
|
||||
"Delete \"{label}\"": "Видалити «{label}»",
|
||||
"Delete \"{label}\" (and the mask)": "Видалити «{label}» (і маску)",
|
||||
@@ -63,6 +63,7 @@
|
||||
"Healed {n} dust spot{}": "Усунено пилових плям: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Імпорт скасовано · додано фото: {}{:.0}",
|
||||
"Import failed: {e}": "Помилка імпорту: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… ({}) або перетягніть їх сюди",
|
||||
"Import warnings ({n}):\n{warnings}": "Попередження імпорту ({n}):\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "Імпортовано наборів налаштувань точкової кривої: {n}{:.0}",
|
||||
"Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}",
|
||||
@@ -117,6 +118,7 @@
|
||||
"Rendering on the CPU: {why}": "Рендеринг на ЦП: {why}",
|
||||
"Replace {} with:": "Замінити {} на:",
|
||||
"Reset {name}": "Скинути: {name}",
|
||||
"{name} value": "{name}: значення",
|
||||
"Reset {} Channel": "Скинути канал: {}",
|
||||
"Restore {}": "Відновити {}",
|
||||
"Restore {} Photo{}": "Відновити фото: {}{:.0}",
|
||||
@@ -165,7 +167,7 @@
|
||||
"{n} photo{}": "Фото: {n}{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "Відповідних фото: {n}{:.0} · оновлюється автоматично зі зміною фото",
|
||||
"{n} photo{} now · updates automatically as photos change": "Зараз фото: {n}{:.0} · оновлюється автоматично зі зміною фото",
|
||||
"{n} selected photos · {pages} pages": "Вибрано фото: {n} · сторінок: {pages}",
|
||||
"{n} selected photo{} · {pages} page{}": "Вибрано фото: {n} · сторінок: {pages}{:.0}{:.0}",
|
||||
"{pid} (missing)": "{pid} (відсутній)",
|
||||
"{sel_n} selected · {counted}": "Вибрано: {sel_n} · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
@@ -251,6 +253,7 @@
|
||||
"{what} merge cancelled": "Об’єднання {what} скасовано",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)",
|
||||
"New Folder in “{name}”": "Нова тека в «{name}»",
|
||||
"Created folder “{name}” in “{parent}”": "Теку «{name}» створено в «{parent}»",
|
||||
"Moved {} photo(s)": "Переміщено фото: {}",
|
||||
"not added to Apple Photos: {e}": "не додано до Apple Фото: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}",
|
||||
@@ -285,7 +288,7 @@
|
||||
"Skipped {problem}": "Пропущено: {problem}",
|
||||
"…and {more} more": "…і ще {more}",
|
||||
"Template preview: {name}": "Перегляд шаблону: {name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}{:.0}{:.0}",
|
||||
"{n} pages": "Сторінок: {n}",
|
||||
"{n} JPEG files": "Файлів JPEG: {n}",
|
||||
"{s} s of AV1 video": "{s} с відео AV1",
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"Home": "Домашня тека",
|
||||
"Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)",
|
||||
"ISO Speed": "Чутливість ISO",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… або перетягніть їх сюди",
|
||||
"Imported": "Імпортовані",
|
||||
"Join the ArtCraft Discord": "Приєднатися до Discord ArtCraft",
|
||||
"Join the ArtCraft Discord…": "Приєднатися до Discord ArtCraft…",
|
||||
@@ -462,6 +463,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "Автоматично позначити фото за журналом маршруту",
|
||||
"Auto-Tag from Tracklog": "Автоматично позначити за журналом маршруту",
|
||||
"Auto-Tag from Tracklog…": "Автоматично позначити за журналом маршруту…",
|
||||
"Add Locations": "Додати місця",
|
||||
"Automatically Write Changes into XMP": "Автоматично записувати зміни в XMP",
|
||||
"Automatically write changes into XMP sidecars": "Автоматично записувати зміни в супровідні файли XMP",
|
||||
"Autumn Gold": "Осіннє золото",
|
||||
@@ -614,6 +616,9 @@
|
||||
"Crop Aspect": "Пропорції кадрування",
|
||||
"Crop, Rotate, Geometry": "Кадрування, обертання, геометрія",
|
||||
"Culling": "Відбір",
|
||||
"Cull": "Відібрати",
|
||||
"Create Stacks": "Створити стоси",
|
||||
"Change Time": "Змінити час",
|
||||
"Custom template…": "Власний шаблон…",
|
||||
"Cycle Crop Overlay": "Перемкнути накладання кадрування",
|
||||
"Cycle Crop Overlay Orientation": "Перемкнути орієнтацію накладання кадрування",
|
||||
@@ -1014,7 +1019,6 @@
|
||||
"Import Profiles & Presets…": "Імпортувати профілі й набори налаштувань…",
|
||||
"Import from Device": "Імпортувати з пристрою",
|
||||
"Import from Folder…": "Імпортувати з теки…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… або перетягніть їх сюди",
|
||||
"Imported Photos": "Імпортовані фото",
|
||||
"Importing": "Імпорт",
|
||||
"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).": "Імпорт сканує джерело фото; він не додає теку до локальних (використовуйте Локальні → Оглянути теку…, щоб працювати в теці без імпорту).",
|
||||
@@ -1451,6 +1455,7 @@
|
||||
"System default": "Типові системні налаштування",
|
||||
"Tags": "Теги",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Теги: див. «Теги» біля шаблону. Файли перейменовуються на диску (разом із супровідними XMP); до наявних назв додається -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Кнопка «Теги» показує, що можна вставити в шаблон, наприклад {date} або {seq}. Кожен файл перейменовується на диску разом із супровідним XMP-файлом; до зайнятої назви додається -1, -2…",
|
||||
"Targeted Adjustment": "Цільова корекція",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "Цільова корекція: тягніть угору/вниз на фото, щоб змінити кольори в цьому місці",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the curve there": "Цільова корекція: тягніть угору/вниз на фото, щоб змінити криву в цьому місці",
|
||||
@@ -2377,5 +2382,8 @@
|
||||
"Set up face recognition": "Налаштувати розпізнавання облич",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Для підказок імен розпізнаванню облич потрібна модель: одноразове завантаження в Налаштуваннях.",
|
||||
"Turn on name suggestions": "Увімкнути підказки імен",
|
||||
"Set up name suggestions…": "Налаштувати підказки імен…"
|
||||
"Set up name suggestions…": "Налаштувати підказки імен…",
|
||||
"Top bar": "Верхня панель",
|
||||
"Tool strip": "Панель інструментів",
|
||||
"Bottom bar": "Нижня панель"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"Create Keyword Tag Inside “{name}”…": "Tạo từ khóa trong «{name}»…",
|
||||
"Create the keyword “{name}”": "Create the keyword “{name}”",
|
||||
"Create {n} Virtual Copies": "Tạo {n} bản sao ảo",
|
||||
"Creates {} stacks from {} of {scope}": "Tạo {} chồng từ {} trong {scope}",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "Tạo {stacks} chồng từ {photos} ảnh ({scope}){:.0}",
|
||||
"Decoder: {reason}": "Bộ giải mã: {reason}",
|
||||
"Delete \"{label}\"": "Xóa «{label}»",
|
||||
"Delete \"{label}\" (and the mask)": "Xóa «{label}» (và mặt nạ)",
|
||||
@@ -95,6 +95,7 @@
|
||||
"Healed {n} dust spot{}": "Đã xử lý {n} vết bụi{:.0}",
|
||||
"Import cancelled · {} photo{} added": "Đã hủy nhập · đã thêm {} ảnh{:.0}",
|
||||
"Import failed: {e}": "Nhập thất bại: {e}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "Nhập ảnh bằng Tệp → Nhập ảnh… ({}), hoặc thả vào đây",
|
||||
"Import warnings ({n}):\n{warnings}": "Cảnh báo khi nhập ({n}):\n{warnings}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)",
|
||||
"Imported {name}": "Imported {name}",
|
||||
@@ -137,6 +138,7 @@
|
||||
"Moved; {} photo(s) relinked": "Đã di chuyển; liên kết lại {} ảnh",
|
||||
"Named ({named_n})": "Có tên ({named_n})",
|
||||
"New Folder in “{name}”": "Thư mục mới trong «{name}»",
|
||||
"Created folder “{name}” in “{parent}”": "Đã tạo thư mục «{name}» trong «{parent}»",
|
||||
"On {have} of {of} selected photos": "Trên {have} / {of} ảnh đã chọn",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "Gán «{k}»: nhấp ảnh để thêm hoặc gỡ · Esc dừng",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "Dán vào {n} ảnh{:.0} — chỉ dán các thiết lập đã sao chép",
|
||||
@@ -168,6 +170,7 @@
|
||||
"Rendering on the CPU: {why}": "Đang kết xuất trên CPU: {why}",
|
||||
"Replace {} with:": "Thay {} bằng:",
|
||||
"Reset {name}": "Đặt lại {name}",
|
||||
"{name} value": "Giá trị {name}",
|
||||
"Reset {} Channel": "Đặt lại kênh {}",
|
||||
"Restore {}": "Khôi phục {}",
|
||||
"Restore {} Photo{}": "Khôi phục {} ảnh{:.0}",
|
||||
@@ -243,15 +246,15 @@
|
||||
"{n} originals opened in the other editor": "Đã mở {n} bản gốc trong trình chỉnh sửa khác",
|
||||
"{n} pages": "{n} pages",
|
||||
"{n} photos": "{n} ảnh",
|
||||
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
|
||||
"{n} photos in {} are hidden by the filters above": "{n} ảnh trong «{}» bị ẩn bởi bộ lọc phía trên",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Đã mở {n} ảnh để chỉnh sửa; mỗi ảnh xếp chồng với bản gốc",
|
||||
"{n} photo{}": "{n} ảnh{:.0}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "{n} ảnh trong chế độ xem · {slides} trang chiếu · {time}{:.0}{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} ảnh{:.0} khớp · tự cập nhật khi ảnh thay đổi",
|
||||
"{n} photo{} now · updates automatically as photos change": "Hiện {n} ảnh{:.0} · tự cập nhật khi ảnh thay đổi",
|
||||
"{n} preset{}": "{n} preset{}",
|
||||
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
|
||||
"{n} selected photo{} · {pages} page{}": "{n} ảnh đã chọn · {pages} trang{:.0}{:.0}",
|
||||
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} ảnh tương tự{:.0} · Xem → Xóa bộ lọc để thấy tất cả",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "Còn {n} tác vụ đang chạy: {names}. Thoát sẽ dừng chúng.",
|
||||
"{n} {noun}": "{noun}: {n}",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"Add Catchlight": "Thêm điểm sáng mắt",
|
||||
"Add Face Model…": "Thêm mô hình khuôn mặt…",
|
||||
"Add Folder to Local": "Thêm thư mục vào Cục bộ",
|
||||
"Add Locations": "Thêm vị trí",
|
||||
"Add Mask": "Thêm mặt nạ",
|
||||
"Add Object Click": "Thêm cú nhấp đối tượng",
|
||||
"Add Point Color Sample": "Thêm mẫu Point Color",
|
||||
@@ -260,6 +261,7 @@
|
||||
"Catchlight": "Điểm sáng mắt",
|
||||
"Change": "Đổi",
|
||||
"Change Component Mode": "Đổi chế độ thành phần",
|
||||
"Change Time": "Đổi thời gian",
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Ký tự không hợp lệ trong tên tệp (/ \\ : * ? \" < > |) thành -; tên đã tồn tại thêm -1, -2….",
|
||||
"Charcoal": "Than",
|
||||
"Check All": "Chọn tất cả",
|
||||
@@ -398,6 +400,7 @@
|
||||
"Create Smart Album from Filter…": "Tạo album thông minh từ bộ lọc…",
|
||||
"Create Smart Album…": "Tạo album thông minh…",
|
||||
"Create Stack": "Tạo chồng",
|
||||
"Create Stacks": "Tạo chồng",
|
||||
"Create Version": "Tạo phiên bản",
|
||||
"Create Version from Step": "Tạo phiên bản từ bước",
|
||||
"Create Virtual Copy": "Tạo bản sao ảo",
|
||||
@@ -412,6 +415,7 @@
|
||||
"Crop, Rotate, Geometry": "Cắt, xoay, hình học",
|
||||
"Cropped": "Đã cắt",
|
||||
"Cross Process": "Cross Process",
|
||||
"Cull": "Sàng lọc",
|
||||
"Culling": "Sàng lọc",
|
||||
"Curve": "Đường cong",
|
||||
"Custom": "Tùy chỉnh",
|
||||
@@ -1845,6 +1849,7 @@
|
||||
"That includes the photos in the folders inside it.": "Bao gồm cả ảnh trong các thư mục bên trong.",
|
||||
"That key can't be used as a shortcut": "Phím đó không thể dùng làm phím tắt",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Đã cài mô hình SAM 3: Mặt nạ đối tượng và Mô tả đã sẵn sàng.",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Nút Thẻ liệt kê các phần giữ chỗ cho mẫu, chẳng hạn {date} hoặc {seq}. Mỗi tệp được đổi tên trên đĩa cùng với tệp phụ XMP của nó; tên đã dùng sẽ thêm -1, -2…",
|
||||
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Catalog có phiên bản định dạng (issue #102). Mở thư viện từ LightCraft cũ hơn sẽ nâng cấp; thư viện",
|
||||
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Cổng điều khiển (`--control`) đóng kết nối ngay khi nhận bất kỳ thứ gì không phải yêu cầu JSON",
|
||||
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Mô hình khử nhiễu đã cài và đang dùng: thanh AI Denoise Amount trong Chi tiết giờ hoạt động",
|
||||
@@ -2676,5 +2681,8 @@
|
||||
"Set up face recognition": "Thiết lập nhận diện khuôn mặt",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "Nhận diện khuôn mặt cần một mô hình để gợi ý tên: tải xuống một lần, trong Cài đặt.",
|
||||
"Turn on name suggestions": "Bật gợi ý tên",
|
||||
"Set up name suggestions…": "Thiết lập gợi ý tên…"
|
||||
"Set up name suggestions…": "Thiết lập gợi ý tên…",
|
||||
"Top bar": "Thanh trên cùng",
|
||||
"Tool strip": "Thanh công cụ",
|
||||
"Bottom bar": "Thanh dưới cùng"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",
|
||||
"Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}",
|
||||
"Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "使用“文件 → 导入照片…”({})导入照片,或将照片拖放到此处",
|
||||
"Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}",
|
||||
"Imported {n} preset{}": "已导入 {n} 个预设{0:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}",
|
||||
@@ -101,7 +102,7 @@
|
||||
"{n} photo{}": "{n} 张照片{0:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}",
|
||||
"{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}",
|
||||
"{n} selected photos · {pages} pages": "已选 {n} 张照片 · {pages} 页",
|
||||
"{n} selected photo{} · {pages} page{}": "已选 {n} 张照片 · {pages} 页{:.0}{:.0}",
|
||||
"{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {0:.0}%",
|
||||
"{what} merge added": "已添加 {what} 合并",
|
||||
@@ -137,6 +138,7 @@
|
||||
"Label Set: {name}": "标签集:{name}",
|
||||
"Quick Develop: {name}": "快速编辑:{name}",
|
||||
"Reset {name}": "重置{name}",
|
||||
"{name} value": "{name}的值",
|
||||
"Updated “{name}”": "已更新“{name}”",
|
||||
"{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}",
|
||||
"{year}": "{year}年",
|
||||
@@ -158,7 +160,7 @@
|
||||
"Auto Import: added {} photo{}": "自动导入:已添加 {0} 张照片{1:.0}",
|
||||
"Building previews for {} photos…": "正在为 {0} 张照片生成预览…",
|
||||
"Couldn't open the editor: {e}": "无法打开编辑器:{e}",
|
||||
"Creates {} stacks from {} of {scope}": "将从{scope}中的 {1} 张照片创建 {0} 个堆叠",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "将从{scope}中的 {photos} 张照片创建 {stacks} 个堆叠{:.0}",
|
||||
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 张照片{plural:.0}",
|
||||
"Focus {:.0}": "对焦 {0:.0}",
|
||||
"Folder “{}” (and its subfolders)": "文件夹“{0}”(含子文件夹)",
|
||||
@@ -255,6 +257,7 @@
|
||||
"{what} merge cancelled": "已取消 {what} 合并",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)",
|
||||
"New Folder in “{name}”": "在“{name}”中新建文件夹",
|
||||
"Created folder “{name}” in “{parent}”": "已在“{parent}”中创建文件夹“{name}”",
|
||||
"Moved {} photo(s)": "已移动 {} 张照片",
|
||||
"not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”",
|
||||
@@ -289,7 +292,7 @@
|
||||
"Skipped {problem}": "已跳过:{problem}",
|
||||
"…and {more} more": "…还有 {more} 个",
|
||||
"Template preview: {name}": "模板预览:{name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}{:.0}{:.0}",
|
||||
"{n} pages": "{n} 页",
|
||||
"{n} JPEG files": "{n} 个 JPEG 文件",
|
||||
"{s} s of AV1 video": "{s} 秒 AV1 视频",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"Add to Local": "添加到本地",
|
||||
"Add to My Photos": "添加到我的照片",
|
||||
"Can't be moved here": "无法移到这里",
|
||||
"Import photos with File → Import Photos…, or drop them here": "使用“文件 → 导入照片…”导入照片,或将照片拖放到此处",
|
||||
"This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动",
|
||||
"Add to Selected Photos": "添加到所选照片",
|
||||
@@ -122,6 +123,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "从轨迹日志自动标记照片",
|
||||
"Auto-Tag from Tracklog": "从轨迹日志自动标记",
|
||||
"Auto-Tag from Tracklog…": "从轨迹日志自动标记…",
|
||||
"Add Locations": "添加位置",
|
||||
"Automatic": "自动",
|
||||
"Automatically Write Changes into XMP": "自动将更改写入 XMP",
|
||||
"Automatically write changes into XMP sidecars": "自动将更改写入 XMP 附属文件",
|
||||
@@ -338,6 +340,9 @@
|
||||
"Crop, Rotate, Geometry": "裁剪、旋转、几何",
|
||||
"Cross Process": "交叉冲洗",
|
||||
"Culling": "挑片",
|
||||
"Cull": "筛选",
|
||||
"Create Stacks": "创建堆叠",
|
||||
"Change Time": "更改时间",
|
||||
"Curve": "曲线",
|
||||
"Custom": "自定义",
|
||||
"Custom template…": "自定义模板…",
|
||||
@@ -804,7 +809,6 @@
|
||||
"Import Profiles & Presets…": "导入配置文件与预设…",
|
||||
"Import from Device": "从设备导入",
|
||||
"Import from Folder…": "从文件夹导入…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "使用“文件 → 导入照片…”导入照片,或将照片拖放到此处",
|
||||
"Imported Photos": "已导入的照片",
|
||||
"Importing": "正在导入",
|
||||
"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).": "导入会扫描源中的照片,但不会将该文件夹添加到“本地”(若想在不导入的情况下处理某个文件夹,请使用“本地 → 浏览文件夹…”)。",
|
||||
@@ -1462,6 +1466,7 @@
|
||||
"Table": "表格",
|
||||
"Tags": "标签",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "“标签”按钮会列出可用于模板的占位符,例如 {date} 或 {seq}。每个文件会连同其 XMP 附属文件在磁盘上重命名;名称已被占用时会加上 -1、-2…",
|
||||
"Take a snapshot": "创建快照",
|
||||
"Targeted Adjustment": "局部调整",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "局部调整:在照片上上下拖动以调整该处的颜色",
|
||||
@@ -2380,5 +2385,8 @@
|
||||
"Set up face recognition": "设置人脸识别",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "人脸识别需要一个模型来建议姓名:只需在“设置”中下载一次。",
|
||||
"Turn on name suggestions": "开启姓名建议",
|
||||
"Set up name suggestions…": "设置姓名建议…"
|
||||
"Set up name suggestions…": "设置姓名建议…",
|
||||
"Top bar": "顶部栏",
|
||||
"Tool strip": "工具栏",
|
||||
"Bottom bar": "底部栏"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"Filter bar — {active} active filter{}": "篩選列 — 已套用 {active} 個篩選條件{:.0}",
|
||||
"Healed {n} dust spot{}": "已修復 {n} 個灰塵斑點{:.0}",
|
||||
"Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.0}",
|
||||
"Import photos with File → Import Photos… ({}), or drop them here": "使用「檔案 → 匯入照片…」({}),或將照片拖放至此",
|
||||
"Imported {n} point curve preset{}": "已匯入 {n} 個點曲線預設集{:.0}",
|
||||
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
|
||||
@@ -82,6 +83,7 @@
|
||||
"Rename “{name}”": "重新命名「{name}」",
|
||||
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
|
||||
"Reset {name}": "重設{name}",
|
||||
"{name} value": "{name}的數值",
|
||||
"Reset {} Channel": "重設 {} 通道",
|
||||
"Restore {}": "還原為 {}",
|
||||
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
|
||||
@@ -121,7 +123,7 @@
|
||||
"{n} photo{}": "{n} 張照片{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新",
|
||||
"{n} selected photos · {pages} pages": "已選取 {n} 張照片 · {pages} 頁",
|
||||
"{n} selected photo{} · {pages} page{}": "已選取 {n} 張照片 · {pages} 頁{:.0}{:.0}",
|
||||
"{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}",
|
||||
@@ -158,7 +160,7 @@
|
||||
"Auto Import: added {} photo{}": "自動匯入:已新增 {} 張照片{:.0}",
|
||||
"Building previews for {} photos…": "正在為 {} 張照片建立預覽…",
|
||||
"Couldn't open the editor: {e}": "無法開啟編輯器:{e}",
|
||||
"Creates {} stacks from {} of {scope}": "將從{scope}中的 {1} 張照片建立 {0} 個堆疊",
|
||||
"Creates {stacks} stack{} from {photos} of {scope}": "將從{scope}中的 {photos} 張照片建立 {stacks} 個堆疊{:.0}",
|
||||
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 張照片{plural:.0}",
|
||||
"Focus {:.0}": "焦點 {:.0}",
|
||||
"Folder “{}” (and its subfolders)": "資料夾「{}」(含子資料夾)",
|
||||
@@ -255,6 +257,7 @@
|
||||
"{what} merge cancelled": "已取消{what}合併",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)",
|
||||
"New Folder in “{name}”": "在「{name}」中新增資料夾",
|
||||
"Created folder “{name}” in “{parent}”": "已在「{parent}」中建立資料夾「{name}」",
|
||||
"Moved {} photo(s)": "已移動 {} 張照片",
|
||||
"not added to Apple Photos: {e}": "未加入 Apple 照片:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」",
|
||||
@@ -289,7 +292,7 @@
|
||||
"Skipped {problem}": "已略過:{problem}",
|
||||
"…and {more} more": "…還有 {more} 個",
|
||||
"Template preview: {name}": "範本預覽:{name}",
|
||||
"{n} photos in view · {slides} slides · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}",
|
||||
"{n} photo{} in view · {slides} slide{} · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}{:.0}{:.0}",
|
||||
"{n} pages": "{n} 頁",
|
||||
"{n} JPEG files": "{n} 個 JPEG 檔案",
|
||||
"{s} s of AV1 video": "{s} 秒 AV1 視訊",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"Add to Local": "加入本機",
|
||||
"Add to My Photos": "加入我的照片",
|
||||
"Can't be moved here": "無法移到這裡",
|
||||
"Import photos with File → Import Photos…, or drop them here": "使用「檔案 → 匯入照片…」,或將照片拖放至此",
|
||||
"This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在照片庫中:請先「加入我的照片」,再移動",
|
||||
"Add to Selected Photos": "加入選取的照片",
|
||||
@@ -122,6 +123,7 @@
|
||||
"Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置",
|
||||
"Auto-Tag from Tracklog": "依軌跡記錄自動加入位置標記",
|
||||
"Auto-Tag from Tracklog…": "依軌跡記錄自動加入位置標記…",
|
||||
"Add Locations": "新增位置",
|
||||
"Automatic": "自動",
|
||||
"Automatically Write Changes into XMP": "自動將變更寫入 XMP",
|
||||
"Automatically write changes into XMP sidecars": "自動將變更寫入 XMP 附屬檔案",
|
||||
@@ -339,6 +341,9 @@
|
||||
"Crop, Rotate, Geometry": "裁切、旋轉、幾何",
|
||||
"Cross Process": "交叉沖洗",
|
||||
"Culling": "挑片",
|
||||
"Cull": "開始挑片",
|
||||
"Create Stacks": "建立堆疊",
|
||||
"Change Time": "變更時間",
|
||||
"Curve": "曲線",
|
||||
"Custom": "自訂",
|
||||
"Custom template…": "自訂範本…",
|
||||
@@ -805,7 +810,6 @@
|
||||
"Import Profiles & Presets…": "匯入描述檔與預設集…",
|
||||
"Import from Device": "從裝置匯入",
|
||||
"Import from Folder…": "從資料夾匯入…",
|
||||
"Import photos with File → Import Photos…, or drop them here": "使用「檔案 → 匯入照片…」,或將照片拖放至此",
|
||||
"Imported Photos": "已匯入的照片",
|
||||
"Importing": "正在匯入",
|
||||
"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).": "匯入會掃描來源中的照片,不會將資料夾加入本機(若要直接使用資料夾中的檔案,請選擇「本機 → 瀏覽資料夾…」)。",
|
||||
@@ -1468,6 +1472,7 @@
|
||||
"Table": "表格",
|
||||
"Tags": "標籤",
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "標籤:請參閱範本旁的「標籤」。檔案及其 XMP 附屬檔案會在磁碟上重新命名;遇到同名檔案會加上 -1、-2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "「標籤」按鈕會列出可用於範本的佔位符,例如 {date} 或 {seq}。每個檔案會連同其 XMP 附屬檔案在磁碟上重新命名;名稱已被使用時會加上 -1、-2…",
|
||||
"Take a snapshot": "擷取快照",
|
||||
"Targeted Adjustment": "目標調整",
|
||||
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "目標調整:在照片上向上/下拖曳,調整該位置的色彩",
|
||||
@@ -2380,5 +2385,8 @@
|
||||
"Set up face recognition": "設定臉部辨識",
|
||||
"Face recognition needs a model to suggest names: a one-time download, in Settings.": "臉部辨識需要一個模型來建議姓名:只需在「設定」中下載一次。",
|
||||
"Turn on name suggestions": "開啟姓名建議",
|
||||
"Set up name suggestions…": "設定姓名建議…"
|
||||
"Set up name suggestions…": "設定姓名建議…",
|
||||
"Top bar": "頂部列",
|
||||
"Tool strip": "工具列",
|
||||
"Bottom bar": "底部列"
|
||||
}
|
||||
|
||||
@@ -430,7 +430,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
"ui.dialog.confirm" => match app.ui.dialog.take() {
|
||||
Some(d) => {
|
||||
let r = crate::panels::dialogs::confirm_dialog(app, &d);
|
||||
// the import review and the rule editor stay open on an error, as with their buttons
|
||||
// a refused one-line prompt says why on screen, as with its button; a folder made says where
|
||||
match (&r, &d) {
|
||||
(Err(e), crate::state::Dialog::TextPrompt { .. }) => app.toast_error(ctx, e.clone()),
|
||||
(Ok(made), crate::state::Dialog::TextPrompt { .. }) => crate::dialogs::toast_created_folder(app, ctx, &d, made),
|
||||
_ => {}
|
||||
}
|
||||
// the import review, the rule editor and the one-line prompts stay open on an error, as with their buttons
|
||||
if (r.is_err()
|
||||
&& matches!(
|
||||
d,
|
||||
@@ -439,6 +445,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
| crate::state::Dialog::SmartRules { .. }
|
||||
| crate::state::Dialog::SaveEffect { .. }
|
||||
| crate::state::Dialog::CaptureTime { .. }
|
||||
| crate::state::Dialog::TextPrompt { .. }
|
||||
))
|
||||
|| (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d))
|
||||
|| (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d))
|
||||
|
||||
+162
-25
@@ -94,8 +94,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// The backdrop is an area below the dialog window (a bare `Middle` layer painter would be
|
||||
// painted after every area — i.e. over the dialog too).
|
||||
// painted after every area — i.e. over the dialog too). Like a macOS sheet, the dialog leaves
|
||||
// the integrated title bar lit: the window buttons the OS draws over it do not dim.
|
||||
let title_bar = title_bar_band(app);
|
||||
egui::Area::new(egui::Id::unique("dialog-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(false).show(ctx, |ui| {
|
||||
let screen = below_title_bar(screen, title_bar);
|
||||
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
|
||||
});
|
||||
let mut close = false;
|
||||
@@ -190,7 +193,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
}
|
||||
if let Some(w) = &shown {
|
||||
// (the import review keeps its hand-picked photos however the click lands)
|
||||
if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) {
|
||||
if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, title_bar, popup_was_open) {
|
||||
close = true;
|
||||
} else if raw_blocked {
|
||||
// A press made under the prompt or library window must not be remembered as a
|
||||
@@ -199,6 +202,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
}
|
||||
}
|
||||
if let Some(w) = shown {
|
||||
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog);
|
||||
ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id));
|
||||
if let Some(body_h) = body_h {
|
||||
let measured = (w.response.rect.height() - body_h).max(0.0);
|
||||
@@ -209,6 +213,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
}
|
||||
ctx.move_to_top(w.response.layer_id);
|
||||
crate::widgets::register_response("dialog:window", w.response.rect, &w.response);
|
||||
if let Some(band) = title_bar {
|
||||
title_bar_handle(ctx, band, w.response.rect);
|
||||
}
|
||||
}
|
||||
// (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in
|
||||
// the dialog, such as a date picker's calendar, takes it to close itself)
|
||||
@@ -232,6 +239,43 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
app.ui.dialog = if close { None } else { Some(dlg) };
|
||||
}
|
||||
|
||||
/// The band of the top bar that doubles as the window's title bar, as drawn last frame: `None`
|
||||
/// without the integrated title bar (Windows, Linux, the web), in macOS full screen, and while the
|
||||
/// bar isn't on screen (the full-screen photo view).
|
||||
fn title_bar_band(app: &LightcraftApp) -> Option<egui::Rect> {
|
||||
// (the full-screen photo view draws no top bar, so the frame it is entered on still holds last frame's region)
|
||||
if !app.integrated_titlebar || app.window_is_fullscreen || app.ui.fullscreen {
|
||||
return None;
|
||||
}
|
||||
app.widgets.iter().find(|(id, _)| id == "region:titlebar").map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
/// `screen` less the title bar band across its top.
|
||||
fn below_title_bar(screen: egui::Rect, band: Option<egui::Rect>) -> egui::Rect {
|
||||
band.map_or(screen, |b| egui::Rect::from_min_max(egui::pos2(screen.min.x, b.max.y.clamp(screen.min.y, screen.max.y)), screen.max))
|
||||
}
|
||||
|
||||
/// The bar's controls are disabled under a dialog, and the modal layer takes the hits of the layers
|
||||
/// below it: this keeps the bar a window handle (drag to move, double-click to zoom), above the
|
||||
/// modal like a sheet's title bar. A tall dialog reaching into the band keeps its own clicks: the
|
||||
/// handle is the band on either side of it, and isn't run while the pointer is on the dialog (each
|
||||
/// handle records whether the window may move, and one the pointer isn't in would say yes).
|
||||
fn title_bar_handle(ctx: &egui::Context, band: egui::Rect, dialog: egui::Rect) {
|
||||
// (egui lifts an area that wasn't there the frame before above the rest: a handle drawn again as the pointer leaves
|
||||
// the dialog would sit over an open dropdown and take the press meant for its item)
|
||||
if egui::Popup::is_any_open(ctx) || ctx.pointer_hover_pos().is_some_and(|p| dialog.contains(p)) {
|
||||
return;
|
||||
}
|
||||
let sides =
|
||||
if band.intersects(dialog) { [(band.min.x, dialog.min.x), (dialog.max.x, band.max.x)] } else { [(band.min.x, band.max.x), (0.0, 0.0)] };
|
||||
for (i, (from, to)) in sides.into_iter().enumerate().filter(|(_, (from, to))| to - from > 1.0) {
|
||||
let part = egui::Rect::from_x_y_ranges(from..=to, band.y_range());
|
||||
egui::Area::new(egui::Id::unique("dialog-title-bar").with(i)).order(egui::Order::Foreground).fixed_pos(part.min).show(ctx, |ui| {
|
||||
craft_window::title_bar::empty_area(ui, part);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The title of a dialog.
|
||||
fn dialog_title(app: &LightcraftApp, dlg: &Dialog) -> String {
|
||||
match dlg {
|
||||
@@ -333,26 +377,69 @@ fn cancel_label(dlg: &Dialog, sam: &SamState) -> &'static str {
|
||||
|
||||
/// The label of a dialog's confirm button (empty: it has none).
|
||||
fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamState) -> String {
|
||||
let label = match dlg {
|
||||
Dialog::Import { opts } => {
|
||||
let n = opts.selected_paths().len();
|
||||
let verb = if opts.copy && opts.move_files { "Move" } else { "Import" };
|
||||
return crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
|
||||
}
|
||||
match dlg {
|
||||
Dialog::Import { opts } => import_ok_label(opts),
|
||||
Dialog::Export { .. } => photos_ok_label("Export", app.session.selection.ids.len().max(1)),
|
||||
Dialog::SamModel { .. } => sam_ok_label(dlg, sam).to_string(),
|
||||
_ => fixed_ok_label(app, dlg, informational).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Import 3 Photos", "Export 1 Photo": a verb and how many photos it acts on.
|
||||
fn photos_ok_label(verb: &str, n: usize) -> String {
|
||||
crate::i18n::tr_format!("{verb} {n} Photo{}", crate::i18n::plural_s(n), verb = crate::i18n::tr(verb), n = n)
|
||||
}
|
||||
|
||||
/// The import review's confirm: "Import 3 Photos", or with Move "Move 3 Photos". A checked file whose photo is in
|
||||
/// Recently Deleted is restored, not moved (its files stay where they are), so Move counts those apart:
|
||||
/// "Move 2 Photos · Restore 1 Photo", or "Restore 1 Photo" when nothing is left to move.
|
||||
fn import_ok_label(opts: &crate::import::ImportDialog) -> String {
|
||||
let n = opts.selected_paths().len();
|
||||
if !(opts.copy && opts.move_files) {
|
||||
return photos_ok_label("Import", n);
|
||||
}
|
||||
let restored = opts.restored_count();
|
||||
match (n - restored, restored) {
|
||||
(_, 0) => photos_ok_label("Move", n),
|
||||
(0, r) => photos_ok_label("Restore", r),
|
||||
(m, r) => format!("{} · {}", photos_ok_label("Move", m), photos_ok_label("Restore", r)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirm labels that are one fixed phrase.
|
||||
fn fixed_ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool) -> &'static str {
|
||||
match dlg {
|
||||
// worded with a count or by the SAM state: `ok_label` builds them
|
||||
Dialog::Import { .. } | Dialog::Export { .. } | Dialog::SamModel { .. } => "",
|
||||
Dialog::LightroomResult { first: Some(_), .. } => "Show in Library",
|
||||
Dialog::ContactSheet { .. } => "Choose PDF…",
|
||||
Dialog::SlideshowExport { .. } => "Export…",
|
||||
Dialog::Print => "Print to PDF…",
|
||||
Dialog::Merge { .. } => "Merge",
|
||||
Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download",
|
||||
Dialog::DenoiseModel { .. } => "Install",
|
||||
Dialog::DenoiseModel { info, .. } if !informational && info["download"].is_string() => "Accept & Download",
|
||||
Dialog::DenoiseModel { .. } if !informational => "Install",
|
||||
Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download",
|
||||
Dialog::FaceModel { .. } if !informational => "Install",
|
||||
Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete",
|
||||
Dialog::SaveEffect { .. } => "Create",
|
||||
Dialog::SaveEffect { .. }
|
||||
| Dialog::NewAlbum { .. }
|
||||
| Dialog::NewSmartAlbum { .. }
|
||||
| Dialog::SmartRules { id: None, .. }
|
||||
| Dialog::CreatePreset { .. } => "Create",
|
||||
Dialog::SmartRules { .. } | Dialog::LabelNames { .. } => "Save",
|
||||
Dialog::RenameKeyword { from, to } if app.session.catalog.has_keyword(to) && !lightcraft_catalog::keywords::same(from, to) => {
|
||||
"Merge Keywords"
|
||||
}
|
||||
Dialog::RenameAlbum { .. } | Dialog::Rename { .. } | Dialog::RenameKeyword { .. } => "Rename",
|
||||
Dialog::AutoStack { .. } => "Create Stacks",
|
||||
Dialog::CaptureTime { .. } => "Change Time",
|
||||
Dialog::Cull { .. } => "Cull",
|
||||
Dialog::CopySettings { .. } => "Copy",
|
||||
Dialog::PasteSettings { .. } => "Paste",
|
||||
Dialog::TextPrompt { command, .. } => prompt_verb(command),
|
||||
Dialog::MovePhotos { .. } => "Move",
|
||||
// (not "Merge": that is photo merging, HDR / panorama, in some languages)
|
||||
Dialog::MoveKeyword { .. } => "Merge Keywords",
|
||||
Dialog::MoveKeyword { .. } | Dialog::MergeKeywords { .. } => "Merge Keywords",
|
||||
Dialog::KeywordSet { .. } => "Save",
|
||||
Dialog::KeywordTag { editing: None, .. } => "Create",
|
||||
Dialog::KeywordTag { .. } => "Save",
|
||||
@@ -361,12 +448,21 @@ fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamSta
|
||||
d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d),
|
||||
Dialog::SynchronizeFolder { .. } if informational => "Close",
|
||||
Dialog::SynchronizeFolder { .. } => "Synchronize",
|
||||
Dialog::SamModel { .. } => return sam_ok_label(dlg, sam).to_string(),
|
||||
Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge",
|
||||
_ if informational => "Close",
|
||||
_ => "OK",
|
||||
};
|
||||
label.to_string()
|
||||
Dialog::RenamePerson { .. } => "Rename",
|
||||
// the dialogs that only show something, or a model file that can't be used
|
||||
Dialog::About
|
||||
| Dialog::Shortcuts
|
||||
| Dialog::Settings { .. }
|
||||
| Dialog::SystemInfo { .. }
|
||||
| Dialog::AllMetadata { .. }
|
||||
| Dialog::WhatsNew
|
||||
| Dialog::ManageEffects { .. }
|
||||
| Dialog::ViewOptions
|
||||
| Dialog::LightroomResult { .. }
|
||||
| Dialog::DenoiseModel { .. }
|
||||
| Dialog::FaceModel { .. } => "Close",
|
||||
}
|
||||
}
|
||||
|
||||
/// The label of the SAM 3 dialog's confirm button.
|
||||
@@ -441,6 +537,17 @@ fn button_row(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &Dialog, close: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Say where a folder made on disk is: the new folder holds no photos yet, so the Folders list may not show it.
|
||||
/// Shared by the dialog's button and `ui.dialog.confirm`.
|
||||
pub(crate) fn toast_created_folder(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &Dialog, made: &serde_json::Value) {
|
||||
if let Dialog::TextPrompt { command, params, .. } = dlg
|
||||
&& command == "folder.create"
|
||||
{
|
||||
let (path, parent) = (made["path"].as_str().unwrap_or_default(), params["parent"].as_str().unwrap_or_default());
|
||||
app.toast(ctx, crate::panels::left::created_text(path, parent));
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's confirm was pressed: do it. Whether the dialog closes.
|
||||
fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> bool {
|
||||
let mut closed = false;
|
||||
@@ -474,6 +581,13 @@ fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) ->
|
||||
*error = Some(e);
|
||||
}
|
||||
}
|
||||
// a one-line prompt that is refused stays open with its text, so the name can be fixed
|
||||
Err(e) if matches!(dlg, Dialog::TextPrompt { .. }) => app.toast_error(ctx, e),
|
||||
// a folder made on disk says so (the new folder holds no photos yet)
|
||||
Ok(r) if matches!(&dlg, Dialog::TextPrompt { command, .. } if command == "folder.create") => {
|
||||
toast_created_folder(app, ctx, dlg, &r);
|
||||
closed = true;
|
||||
}
|
||||
Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => {
|
||||
// albums hold library photos (issue #684): say which part of the selection stayed out
|
||||
crate::panels::grid::toast_skipped(app, ctx, &r);
|
||||
@@ -516,6 +630,19 @@ fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) ->
|
||||
/// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`].
|
||||
const BACKDROP_SHOWN: &str = "dialog-backdrop-shown";
|
||||
|
||||
/// What a one-field prompt's confirm button says, by the command it runs. A command that isn't
|
||||
/// listed (one opened by automation) has no verb to name and keeps "OK".
|
||||
fn prompt_verb(command: &str) -> &'static str {
|
||||
match command {
|
||||
"album.rename" | "folder.rename" | "version.rename" | "preset.rename" | "mask.renameEffect" => "Rename",
|
||||
"folder.create" | "mask.setEffectGroup" | "app.newLibrary" => "Create",
|
||||
"preset.move" => "Move",
|
||||
"keyword.saveSet" | "filter.savePreset" | "curve.savePreset" | "metadata.savePreset" => "Save",
|
||||
"photo.tagFromTracklog" => "Add Locations",
|
||||
_ => "OK",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a dialog stays open after its action succeeded (the SAM 3 dialog while the model
|
||||
/// downloads).
|
||||
pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool {
|
||||
@@ -537,12 +664,16 @@ fn auto_stack_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg:
|
||||
let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default();
|
||||
let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" };
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr_format!(
|
||||
"Creates {} stacks from {} of {scope}",
|
||||
preview["stacks"],
|
||||
preview["photos"],
|
||||
scope = crate::i18n::tr(scope)
|
||||
))
|
||||
egui::RichText::new({
|
||||
let (stacks, photos) = (preview["stacks"].as_u64().unwrap_or(0), preview["photos"].as_u64().unwrap_or(0));
|
||||
crate::i18n::tr_format!(
|
||||
"Creates {stacks} stack{} from {photos} of {scope}",
|
||||
if stacks == 1 { "" } else { "s" },
|
||||
stacks = stacks,
|
||||
photos = photos,
|
||||
scope = crate::i18n::tr(scope)
|
||||
)
|
||||
})
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
@@ -914,7 +1045,7 @@ fn rename_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut
|
||||
field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999)));
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr(
|
||||
"Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…",
|
||||
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…",
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
@@ -1061,7 +1192,8 @@ fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dial
|
||||
options.columns = options.columns.clamp(1, 8);
|
||||
options.rows = options.rows.clamp(1, 10);
|
||||
let n = app.session.selected().len();
|
||||
ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows)));
|
||||
let pages = n.div_ceil(options.columns * options.rows);
|
||||
ui.label(contact_sheet_summary(n, pages));
|
||||
field(ui, "Paper", |ui| {
|
||||
let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut options.paper, "a4".into(), "A4");
|
||||
@@ -1081,6 +1213,11 @@ fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dial
|
||||
ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer."));
|
||||
}
|
||||
|
||||
/// "N selected photos · M pages".
|
||||
fn contact_sheet_summary(n: usize, pages: usize) -> String {
|
||||
crate::i18n::tr_format!("{n} selected photo{} · {pages} page{}", crate::i18n::plural_s(n), crate::i18n::plural_s(pages), n = n, pages = pages)
|
||||
}
|
||||
|
||||
/// The body of the Export dialog.
|
||||
fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) {
|
||||
let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return };
|
||||
|
||||
@@ -56,6 +56,18 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
}
|
||||
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
|
||||
Dialog::TextPrompt { value, command, params, key, .. } => {
|
||||
// Rename Folder opens with the folder's own name typed: confirming it unchanged renames nothing, as it always has
|
||||
// (the engine would refuse it as a name that "already exists"). A name with spaces at its ends is the same
|
||||
// folder when typed back as it is, so it is compared as typed as well as trimmed.
|
||||
let kept = command == "folder.rename"
|
||||
&& params["path"].as_str().is_some_and(|path| {
|
||||
std::path::Path::new(path.trim_end_matches(['/', '\\']))
|
||||
.file_name()
|
||||
.is_some_and(|f| f.to_string_lossy() == value.as_str() || f.to_string_lossy() == value.trim())
|
||||
});
|
||||
if kept {
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
let mut p = params.clone();
|
||||
p[key.as_str()] = json!(value.trim());
|
||||
app.run(command, p)
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{BACKDROP_SHOWN, ControlSpec, Dialog, LightcraftApp, Tokens};
|
||||
/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that
|
||||
/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never
|
||||
/// closes it. (Read from the raw events: egui forgets the press origin on release.)
|
||||
pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool {
|
||||
pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, title_bar: Option<egui::Rect>, popup_was_open: bool) -> bool {
|
||||
let press_id = egui::Id::unique("dialog-backdrop-press");
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
// the same frame too: a second pass of it gets no input
|
||||
@@ -27,12 +27,12 @@ pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_wa
|
||||
(at(true), at(false), i.pointer.primary_clicked())
|
||||
});
|
||||
if let Some(p) = press {
|
||||
let outside = !window.contains(p) && !popup_was_open;
|
||||
let outside = !window.contains(p) && !title_bar.is_some_and(|b| b.contains(p)) && !popup_was_open;
|
||||
ctx.data_mut(|d| d.insert_temp(press_id, outside));
|
||||
}
|
||||
let Some(release) = release else { return false };
|
||||
let pressed_outside = ctx.data_mut(|d| d.remove_temp::<bool>(press_id)).unwrap_or(false);
|
||||
clicked && pressed_outside && !window.contains(release)
|
||||
clicked && pressed_outside && !window.contains(release) && !title_bar.is_some_and(|b| b.contains(release))
|
||||
}
|
||||
|
||||
/// Room kept free above and below a dialog (#781).
|
||||
|
||||
@@ -198,6 +198,13 @@ impl ImportDialog {
|
||||
}
|
||||
self.last_clicked = Some(i);
|
||||
}
|
||||
/// How many of the checked files come back as the photo Recently Deleted holds: the import restores
|
||||
/// those without copying or moving their files (only "Import as new" imports them like any other file).
|
||||
pub fn restored_count(&self) -> usize {
|
||||
let restoring = self.on_deleted != "fresh";
|
||||
let checked = self.checked.iter().enumerate().filter(|(i, on)| **on && self.importable(*i));
|
||||
checked.filter(|(i, _)| restoring && self.is_trashed(*i)).count()
|
||||
}
|
||||
pub fn selected_paths(&self) -> Vec<String> {
|
||||
self.candidates
|
||||
.iter()
|
||||
|
||||
@@ -77,7 +77,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS).
|
||||
/// `Cmd+Shift+Z` as menus show it (`⇧⌘Z` on macOS).
|
||||
fn menu_text(sc: &str, mac: bool) -> String {
|
||||
crate::menubar::shortcut_text(sc, mac)
|
||||
}
|
||||
|
||||
@@ -106,11 +106,16 @@ const PREVIEW_CHROME: f32 = 82.0;
|
||||
pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) {
|
||||
ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x));
|
||||
let top = ui.cursor().top();
|
||||
// The page drawn is the one that was open when the frame began: a tab clicked now opens on
|
||||
// the next frame, so a text box on the page being left is drawn once more and saves what was
|
||||
// typed in it (the click took the focus from it).
|
||||
let shown = tab.clone();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
|
||||
for (id, label) in TABS {
|
||||
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() {
|
||||
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, shown == *id).clicked() {
|
||||
*tab = id.to_string();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -118,7 +123,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_
|
||||
// (a point less than the room left, so rounding never makes the dialog's own scroll area scroll)
|
||||
let room = max_height - (ui.cursor().top() - top) - 1.0;
|
||||
if room < PAGE_MIN_HEIGHT {
|
||||
body(app, ui, tab.as_str());
|
||||
body(app, ui, shown.as_str());
|
||||
register(ui, "settings:page", ui.min_rect());
|
||||
return;
|
||||
}
|
||||
@@ -129,7 +134,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_
|
||||
// (at least the least tall slide); where that leaves less than [`PAGE_MIN_HEIGHT`] for the
|
||||
// page, it scrolls with the page instead (as in the short window above).
|
||||
let mut pinned = false;
|
||||
if tab == "slideshow" {
|
||||
if shown == "slideshow" {
|
||||
let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(crate::panels::slide_layout::PREVIEW_MIN_H);
|
||||
if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT {
|
||||
let before = ui.cursor().top();
|
||||
@@ -144,7 +149,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_
|
||||
// or two too long looks cut off, not scrollable.
|
||||
ui.spacing_mut().scroll.dormant_handle_opacity = 0.6;
|
||||
let page = egui::ScrollArea::vertical()
|
||||
.id_salt(("settings-page", tab.as_str()))
|
||||
.id_salt(("settings-page", shown.as_str()))
|
||||
.max_height(height)
|
||||
.min_scrolled_height(height)
|
||||
.auto_shrink([false, false])
|
||||
@@ -153,7 +158,7 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_
|
||||
if pinned {
|
||||
slideshow_tab(app, ui, &Tokens::get(ui.ctx()), false);
|
||||
} else {
|
||||
body(app, ui, tab.as_str());
|
||||
body(app, ui, shown.as_str());
|
||||
}
|
||||
// (what the page took: wider than the room for it would mean a control is cut off)
|
||||
register(ui, "settings:page", ui.min_rect());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::widgets::{check, choices, heading, hint, path_label, row};
|
||||
use super::widgets::{check, choices, field, heading, hint, path_label, row};
|
||||
use super::{LightcraftApp, RichText, StartupView, Tokens, json, register};
|
||||
|
||||
// ------------------------------------------------------------------------------------- General
|
||||
@@ -77,10 +77,12 @@ pub(super) fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens
|
||||
check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it");
|
||||
heading(ui, t, crate::i18n::tr("External Editing"));
|
||||
row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| {
|
||||
crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path)
|
||||
.hint(crate::i18n::tr("Found automatically"))
|
||||
.width(220.0)
|
||||
.show(ui);
|
||||
field(
|
||||
ui,
|
||||
crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path)
|
||||
.hint(crate::i18n::tr("Found automatically"))
|
||||
.width(220.0),
|
||||
);
|
||||
});
|
||||
edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file);
|
||||
hint(
|
||||
@@ -91,10 +93,12 @@ pub(super) fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens
|
||||
),
|
||||
);
|
||||
row(ui, t, crate::i18n::tr("Other editor"), |ui| {
|
||||
crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor)
|
||||
.hint(crate::i18n::tr("System default"))
|
||||
.width(220.0)
|
||||
.show(ui);
|
||||
field(
|
||||
ui,
|
||||
crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor)
|
||||
.hint(crate::i18n::tr("System default"))
|
||||
.width(220.0),
|
||||
);
|
||||
});
|
||||
edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file);
|
||||
hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere)."));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::general::{cameras, preset_combo};
|
||||
use super::widgets::{button_width, check, choices, heading, hint, path_label, row};
|
||||
use super::widgets::{button_width, check, choices, field, heading, hint, path_label, row};
|
||||
use super::{LightcraftApp, RichText, Tokens, json, register};
|
||||
|
||||
pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
@@ -79,12 +79,14 @@ pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens)
|
||||
row(ui, t, label, |ui| {
|
||||
let id = egui::Id::unique(("settingsMeta", key));
|
||||
let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0));
|
||||
register(ui, format!("field:settings.{key}"), r.rect);
|
||||
if r.lost_focus() && text.trim() != value {
|
||||
// (the project's text field: Esc gives the text back and stays in Settings, and with
|
||||
// the text back the line below has nothing to save)
|
||||
let widget = format!("field:settings.{key}");
|
||||
let shown = field(ui, crate::text_field::TextField::singleline(&widget, &mut text).hint(hint_text).width(240.0));
|
||||
if shown.committed() && text.trim() != value {
|
||||
let _ = app.run("library.preferences", json!({"import": {key: text.trim()}}));
|
||||
}
|
||||
if r.has_focus() {
|
||||
if shown.editing {
|
||||
ui.data_mut(|m| m.insert_temp(id, text));
|
||||
} else {
|
||||
ui.data_mut(|m| m.remove::<String>(id));
|
||||
@@ -180,10 +182,14 @@ pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens)
|
||||
}
|
||||
});
|
||||
row(ui, t, crate::i18n::tr("Album"), |ui| {
|
||||
let id = egui::Id::unique("auto-album");
|
||||
// (per library: a name left here by one library is not another's)
|
||||
let id = egui::Id::unique(("auto-album", app.session.library.as_ref().map(|l| l.dir.clone())));
|
||||
let mut name = ui.data(|m| m.get_temp::<String>(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0));
|
||||
if r.lost_focus() {
|
||||
let shown = field(
|
||||
ui,
|
||||
crate::text_field::TextField::singleline("field:settingsAutoAlbum", &mut name).hint(crate::i18n::tr("None")).width(180.0),
|
||||
);
|
||||
if shown.committed() {
|
||||
let _ = app.run("library.autoImport", json!({"album": name.trim()}));
|
||||
}
|
||||
ui.data_mut(|m| m.insert_temp(id, name));
|
||||
|
||||
@@ -49,6 +49,16 @@ pub(super) fn button_width(ui: &egui::Ui, text: &str) -> f32 {
|
||||
label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// A text box of the Settings window, shown: Esc in it gives its text back and is used up by
|
||||
/// that, so Settings stays open (a second Esc, with nothing being edited, closes it).
|
||||
pub(super) fn field(ui: &mut egui::Ui, field: crate::text_field::TextField<'_>) -> crate::text_field::TextFieldResponse {
|
||||
let shown = field.show(ui);
|
||||
if shown.cancelled() {
|
||||
crate::widgets::take_escape(ui.ctx());
|
||||
}
|
||||
shown
|
||||
}
|
||||
|
||||
pub(crate) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
|
||||
|
||||
@@ -79,35 +79,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
pub(super) fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) {
|
||||
use lightcraft_engine::slideshow_export::{ExportSettings, Format};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let photos = app.session.visible_cloned().len();
|
||||
let slide = app.session.slideshow.slide_duration;
|
||||
let (intro, ending) = app.session.slide_titles.durations(slide);
|
||||
let titles = usize::from(intro.is_some()) + usize::from(ending.is_some());
|
||||
let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0);
|
||||
let summary = crate::i18n::tr_format!(
|
||||
"{n} photos in view · {slides} slides · {time}",
|
||||
n = photos,
|
||||
slides = photos + titles,
|
||||
time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64)
|
||||
);
|
||||
ui.label(egui::RichText::new(summary).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(slideshow_summary(app)).color(t.text_dim));
|
||||
let before = settings.format;
|
||||
choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format);
|
||||
if settings.format != before {
|
||||
*settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) };
|
||||
}
|
||||
match settings.format {
|
||||
Format::Video => {
|
||||
choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size);
|
||||
choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps);
|
||||
let mut webm = settings.container == "webm";
|
||||
choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm);
|
||||
settings.container = if webm { "webm".into() } else { "mp4".into() };
|
||||
}
|
||||
_ => {
|
||||
choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size);
|
||||
}
|
||||
}
|
||||
slideshow_size_rows(ui, settings);
|
||||
field(ui, "Quality", |ui| {
|
||||
let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100));
|
||||
crate::widgets::register(ui, "slider:slideshowExportQuality", r.rect);
|
||||
@@ -128,6 +106,46 @@ pub(super) fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui,
|
||||
);
|
||||
}
|
||||
|
||||
/// How many photos are in view, how many slides they make with the intro and ending screens, and how long it plays.
|
||||
fn slideshow_counts(app: &mut LightcraftApp) -> (usize, usize, f64) {
|
||||
let photos = app.session.visible_cloned().len();
|
||||
let slide = app.session.slideshow.slide_duration;
|
||||
let (intro, ending) = app.session.slide_titles.durations(slide);
|
||||
let titles = usize::from(intro.is_some()) + usize::from(ending.is_some());
|
||||
(photos, photos + titles, photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// "N photos in view · M slides · m:ss".
|
||||
fn slideshow_summary(app: &mut LightcraftApp) -> String {
|
||||
let (photos, slides, seconds) = slideshow_counts(app);
|
||||
let time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64);
|
||||
crate::i18n::tr_format!(
|
||||
"{n} photo{} in view · {slides} slide{} · {time}",
|
||||
crate::i18n::plural_s(photos),
|
||||
crate::i18n::plural_s(slides),
|
||||
n = photos,
|
||||
slides = slides,
|
||||
time = time
|
||||
)
|
||||
}
|
||||
|
||||
/// The size choices of the chosen format, and a video's frame rate and container.
|
||||
fn slideshow_size_rows(ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) {
|
||||
use lightcraft_engine::slideshow_export::Format;
|
||||
match settings.format {
|
||||
Format::Video => {
|
||||
choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size);
|
||||
choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps);
|
||||
let mut webm = settings.container == "webm";
|
||||
choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm);
|
||||
settings.container = if webm { "webm".into() } else { "mp4".into() };
|
||||
}
|
||||
_ => {
|
||||
choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
|
||||
if app.updates_rt.manual_window_open {
|
||||
let mut open = true;
|
||||
egui::Window::new(crate::i18n::tr("LightCraft Updates"))
|
||||
let shown = egui::Window::new(crate::i18n::tr("LightCraft Updates"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -136,6 +136,9 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
app.updates_rt.manual_window_open = false;
|
||||
}
|
||||
});
|
||||
if let Some(w) = &shown {
|
||||
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Window);
|
||||
}
|
||||
if !open {
|
||||
app.updates_rt.manual_window_open = false;
|
||||
}
|
||||
|
||||
@@ -402,17 +402,24 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
panel_limits,
|
||||
|ui, panel| match panel {
|
||||
Panel::Canvas => {
|
||||
canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect())));
|
||||
let child = ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()));
|
||||
crate::widgets::announce_region(&child, egui::accesskit::Role::Main, crate::i18n::tr(Panel::Canvas.label()));
|
||||
canvas = Some(child);
|
||||
}
|
||||
Panel::Library => {
|
||||
crate::widgets::announce_region(ui, egui::accesskit::Role::Navigation, crate::i18n::tr(Panel::Library.label()));
|
||||
crate::widgets::register(ui, "panel:left_panel", ui.max_rect());
|
||||
crate::panels::left::show(app, ui);
|
||||
}
|
||||
Panel::Tools => {
|
||||
crate::widgets::announce_region(ui, egui::accesskit::Role::Complementary, crate::i18n::tr(Panel::Tools.label()));
|
||||
crate::widgets::register(ui, "panel:right_panel", ui.max_rect());
|
||||
crate::panels::right::show(app, ui);
|
||||
}
|
||||
Panel::Presets => crate::panels::presets::show(app, ui),
|
||||
Panel::Presets => {
|
||||
crate::widgets::announce_region(ui, egui::accesskit::Role::Complementary, crate::i18n::tr(Panel::Presets.label()));
|
||||
crate::panels::presets::show(app, ui);
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(mut canvas) = canvas {
|
||||
|
||||
@@ -996,20 +996,22 @@ pub fn run_native_command(app: &mut LightcraftApp, id: &str, params: Value) -> N
|
||||
policy
|
||||
}
|
||||
|
||||
/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere.
|
||||
/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⇧⌘Z` on macOS, `Ctrl+Shift+Z` elsewhere.
|
||||
/// On macOS the modifiers come in AppKit's and Apple's HIG order, ⌃⌥⇧⌘, however the binding spells them.
|
||||
pub fn shortcut_text(sc: &str, mac: bool) -> String {
|
||||
if mac {
|
||||
let mut mods = String::new();
|
||||
let (mut ctrl, mut alt, mut shift, mut cmd) = (false, false, false, false);
|
||||
let mut key = "";
|
||||
for part in sc.split('+') {
|
||||
match part {
|
||||
"Ctrl" => mods.push('⌃'),
|
||||
"Alt" => mods.push('⌥'),
|
||||
"Shift" => mods.push('⇧'),
|
||||
"Cmd" => mods.push('⌘'),
|
||||
"Ctrl" => ctrl = true,
|
||||
"Alt" => alt = true,
|
||||
"Shift" => shift = true,
|
||||
"Cmd" => cmd = true,
|
||||
k => key = k,
|
||||
}
|
||||
}
|
||||
let mods: String = [(ctrl, '⌃'), (alt, '⌥'), (shift, '⇧'), (cmd, '⌘')].into_iter().filter_map(|(on, glyph)| on.then_some(glyph)).collect();
|
||||
let key = match key {
|
||||
"Delete" => "⌫",
|
||||
"Escape" => "⎋",
|
||||
|
||||
@@ -43,7 +43,7 @@ fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() {
|
||||
assert_eq!(label, "Photo Grid");
|
||||
assert_eq!(accelerator, Some("G"));
|
||||
assert!(!native_menu_delivers(accelerator.unwrap()));
|
||||
for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] {
|
||||
for (key, text) in [("Cmd+Shift+G", "⇧⌘G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] {
|
||||
crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap();
|
||||
let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key));
|
||||
assert_eq!(label, format!("Photo Grid ({text})"));
|
||||
@@ -767,11 +767,23 @@ fn native_menu_commands_respect_the_blocking_window_policy() {
|
||||
|
||||
#[test]
|
||||
fn shortcut_text_per_platform() {
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⇧⌘Z");
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z");
|
||||
assert_eq!(shortcut_text("Delete", true), "⌫");
|
||||
}
|
||||
|
||||
/// AppKit and Apple's Human Interface Guidelines write modifiers ⌃⌥⇧⌘ whatever order the binding
|
||||
/// was spelled in, so the hint on a button matches the menu bar's own column.
|
||||
#[test]
|
||||
fn shortcut_text_orders_mac_modifiers_control_option_shift_command() {
|
||||
assert_eq!(shortcut_text("Cmd+Alt+Shift+C", true), "⌥⇧⌘C");
|
||||
assert_eq!(shortcut_text("Shift+Alt+Ctrl+Cmd+K", true), "⌃⌥⇧⌘K");
|
||||
assert_eq!(shortcut_text("Cmd+Ctrl+F", true), "⌃⌘F");
|
||||
assert_eq!(shortcut_text("Shift+Cmd+Up", true), "⇧⌘↑");
|
||||
assert_eq!(shortcut_text("Shift+Alt+Y", true), "⌥⇧Y");
|
||||
assert_eq!(shortcut_text("Cmd+Alt+Shift+C", false), "Ctrl+Alt+Shift+C", "other platforms keep the binding as written");
|
||||
}
|
||||
|
||||
/// Every item of a menu, submenus included.
|
||||
fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> {
|
||||
nodes
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
.exact_size(t.bottom_bar_h)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Bottom bar"));
|
||||
let full = ui.max_rect();
|
||||
let compact_zoom = full.width() < 360.0;
|
||||
let right_start = right_side(app, ui, full, compact_zoom);
|
||||
|
||||
@@ -65,10 +65,14 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
if ids.is_empty() {
|
||||
let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim);
|
||||
// a label, not painted text, so a screen reader reads the reason; the region keeps the strip navigable
|
||||
let mut empty = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::centered_and_justified(egui::Direction::TopDown)));
|
||||
crate::widgets::announce_region(&empty, egui::accesskit::Role::Region, crate::i18n::tr("Filmstrip"));
|
||||
empty.add(egui::Label::new(egui::RichText::new(why).font(t.font(12.5)).color(t.text_dim)).selectable(false));
|
||||
return;
|
||||
}
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0))));
|
||||
crate::widgets::announce_region(&child, egui::accesskit::Role::Region, crate::i18n::tr("Filmstrip"));
|
||||
// a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work)
|
||||
child.style_mut().always_scroll_the_only_direction = true;
|
||||
// only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active`
|
||||
|
||||
@@ -347,7 +347,7 @@ mod tests {
|
||||
fn reset_tooltip_names_the_shortcut_of_this_platform() {
|
||||
let keymap = crate::shortcuts::Keymap::new();
|
||||
assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)");
|
||||
assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)");
|
||||
assert_eq!(reset_tip(&keymap, true), "Reset all edits (⇧⌘R)");
|
||||
let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into();
|
||||
assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)");
|
||||
let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into();
|
||||
|
||||
@@ -49,6 +49,18 @@ pub fn hint(keymap: &Keymap, mac: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's second line when a view with no photos is not the empty library (an empty album, say):
|
||||
/// the way in is File → Import Photos…, with the shortcut that command is bound to now, written as
|
||||
/// menus write it. With no shortcut the sentence leaves it out.
|
||||
pub fn no_photos_body(keymap: &Keymap, mac: bool) -> String {
|
||||
match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") {
|
||||
Some(sc) => {
|
||||
crate::i18n::tr_format!("Import photos with File → Import Photos… ({}), or drop them here", crate::menubar::shortcut_text(sc, mac))
|
||||
}
|
||||
None => crate::i18n::tr("Import photos with File → Import Photos…, or drop them here").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const IMPORT_PHOTOS: EmptyAction = EmptyAction { command: "file.addPhotos", label: "Import Photos…", widget: "button:emptyImportPhotos" };
|
||||
const IMPORT_LIGHTROOM: EmptyAction =
|
||||
EmptyAction { command: "file.importLightroom", label: "Import Lightroom Catalog…", widget: "button:emptyImportLightroom" };
|
||||
|
||||
@@ -23,7 +23,7 @@ pub use albums::{album_drag_feedback, auto_scroll_speed};
|
||||
use albums::{albums_rows, drag_auto_scroll};
|
||||
pub(crate) use dates::browse_all_photos;
|
||||
use dates::date_rows;
|
||||
pub(crate) use folders::{folder_row_of, move_photos, moved_text};
|
||||
pub(crate) use folders::{created_text, folder_row_of, move_photos, moved_text};
|
||||
use folders::{folder_rows, reveal_chosen};
|
||||
use keywords::keyword_rows;
|
||||
pub(crate) use local::{fs_cached, fs_cached_running, same_folder};
|
||||
|
||||
@@ -481,6 +481,13 @@ pub(crate) fn moved_text(n: u64, name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `folder.create` of the folder at `path` inside `parent` says when it succeeds: the folder
|
||||
/// is on disk now, but it has no photos, so the Folders list may not show it yet.
|
||||
pub(crate) fn created_text(path: &str, parent: &str) -> String {
|
||||
let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |f| f.to_string_lossy().to_string());
|
||||
crate::i18n::tr_format!("Created folder “{name}” in “{parent}”", name = name, parent = lightcraft_catalog::folders::folder_label(parent))
|
||||
}
|
||||
|
||||
/// [`move_photos`], saying how it went in a toast.
|
||||
fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) {
|
||||
match move_photos(app, ids, path) {
|
||||
|
||||
@@ -28,6 +28,10 @@ use crate::widgets::register;
|
||||
/// windows and their dim.
|
||||
const DIM_ID: &str = "library-problem-dim";
|
||||
const ERROR_WINDOW_ID: &str = "library-problem";
|
||||
/// What the error window says about the library's safety and the way on (drawn as it is, not translated).
|
||||
const EXPLANATION: &str = "Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \
|
||||
or reconnecting its drive), open a different library or create a new one, or continue without a library — \
|
||||
then nothing you do is saved.";
|
||||
const CHOOSER_WINDOW_ID: &str = "library-chooser";
|
||||
|
||||
/// The named `Foreground` layer used by the dim and the two blocking library windows.
|
||||
@@ -1082,6 +1086,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
dim(ctx);
|
||||
let mut choice = None;
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
|
||||
// what the window says, in one piece, for a screen reader: the labels below are separate nodes
|
||||
let description = if problem.path.is_empty() {
|
||||
format!("{} {} {EXPLANATION}", crate::i18n::tr("LightCraft couldn't find where to keep your library."), problem.error)
|
||||
} else {
|
||||
format!("{} {}. {} {EXPLANATION}", crate::i18n::tr("LightCraft couldn't open the library at"), problem.path, problem.error)
|
||||
};
|
||||
let shown = egui::Window::new(crate::i18n::tr("Your library couldn't be opened"))
|
||||
.id(egui::Id::unique(ERROR_WINDOW_ID))
|
||||
.order(egui::Order::Foreground)
|
||||
@@ -1101,14 +1111,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text));
|
||||
}
|
||||
ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap());
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \
|
||||
or reconnecting its drive), open a different library or create a new one, or continue without a library — \
|
||||
then nothing you do is saved.",
|
||||
)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.label(RichText::new(EXPLANATION).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| {
|
||||
@@ -1125,6 +1128,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
});
|
||||
});
|
||||
if let Some(w) = &shown {
|
||||
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Alert);
|
||||
crate::widgets::describe_dialog(ctx, &w.response, &description);
|
||||
// A press on the dim raises its area; keep this window (and its own buttons) above it.
|
||||
ctx.move_to_top(w.response.layer_id);
|
||||
}
|
||||
@@ -1247,6 +1252,7 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP
|
||||
});
|
||||
});
|
||||
if let Some(w) = &shown {
|
||||
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog);
|
||||
// A press on the dim raises its area; keep the launch list above it.
|
||||
ctx.move_to_top(w.response.layer_id);
|
||||
}
|
||||
|
||||
@@ -518,6 +518,8 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
|
||||
});
|
||||
// A backdrop press can raise its area; keep the prompt buttons above it.
|
||||
if let Some(window) = shown {
|
||||
crate::widgets::announce_dialog(ctx, &window.response, crate::widgets::Modal::Alert);
|
||||
crate::widgets::describe_dialog(ctx, &window.response, text);
|
||||
ctx.move_to_top(window.response.layer_id);
|
||||
}
|
||||
input.button_ids = seen;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
.stroke(egui::Stroke::new(1.0, t.divider)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Tool strip"));
|
||||
let has_photo = app.session.active().is_some();
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = GAP;
|
||||
|
||||
@@ -28,10 +28,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
(true, true) => 16,
|
||||
(false, _) => 10,
|
||||
};
|
||||
egui::Panel::top("top_bar")
|
||||
// A dialog fades the disabled bar's controls, and its fill with them. A sheet leaves the title bar's
|
||||
// color alone (the window buttons the OS draws over it stay lit), so the fill is painted unfaded
|
||||
// beneath, in a slot reserved before the bar's own shapes.
|
||||
let sheet = app.integrated_titlebar && !app.window_is_fullscreen && app.ui.dialog.is_some();
|
||||
let slot = sheet.then(|| ui.ctx().layer_painter(ui.layer_id()).add(egui::Shape::Noop));
|
||||
let fill = if sheet { egui::Color32::TRANSPARENT } else { t.chrome };
|
||||
let panel = egui::Panel::top("top_bar")
|
||||
.exact_size(t.top_bar_h)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
|
||||
.frame(egui::Frame::NONE.fill(fill).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Top bar"));
|
||||
let full = ui.max_rect();
|
||||
// The whole bar, its margins included. The search field is centred on this and not on
|
||||
// `full`, which is only what the margins leave: on macOS the left margin holds the
|
||||
@@ -57,6 +64,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let x = right_icons(app, ui, &t, full);
|
||||
appearance_button(app, ui, &t, full, x);
|
||||
});
|
||||
if let Some(slot) = slot {
|
||||
ui.ctx().layer_painter(ui.layer_id()).set(slot, egui::Shape::rect_filled(panel.response.rect, 0.0, t.chrome));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar toggle, back/forward and, without a native menu bar, the in-window menus.
|
||||
@@ -180,8 +190,11 @@ fn right_icons(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rec
|
||||
("bell", Icon::Bell, "Activity", "panel.activity"),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip));
|
||||
crate::widgets::announce(&resp, crate::i18n::tr(tip));
|
||||
let tip = crate::i18n::tr(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click());
|
||||
let name = crate::widgets::accessible_label(&resp, tip);
|
||||
let resp = resp.on_hover_text(tip);
|
||||
crate::widgets::announce(&resp, name);
|
||||
register(ui, format!("icon:{id}"), r);
|
||||
let warn = id == "cloud" && unsaved.is_some();
|
||||
let colour = if warn {
|
||||
|
||||
@@ -16,9 +16,11 @@ mod crop_rotate;
|
||||
mod crop_tool;
|
||||
mod curve;
|
||||
mod date_picker;
|
||||
mod dialog_copy;
|
||||
mod dialog_fit;
|
||||
mod dialog_primary;
|
||||
mod dialog_settings;
|
||||
mod dialog_settings_text;
|
||||
mod dialog_tab;
|
||||
mod dialogs;
|
||||
mod docking_acceptance;
|
||||
@@ -83,6 +85,7 @@ mod switch_library;
|
||||
mod sync;
|
||||
mod tallies;
|
||||
mod temp_slider;
|
||||
mod text_prompt;
|
||||
mod titlebar;
|
||||
mod tool_overlay;
|
||||
mod unsaved;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! A dialog's confirm button names what it does ("Rename", "Create", "Create Stacks"…), not "OK":
|
||||
//! the button is the last thing read before a change is made. Every check reads the text painted
|
||||
//! inside the `button:dialogOk` widget itself, not just any text in the window.
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn demo() -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
|
||||
h.settle(T);
|
||||
h
|
||||
}
|
||||
|
||||
fn show(h: &mut Headless, dialog: Dialog) {
|
||||
h.app.ui.dialog = Some(dialog);
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn dialog(value: serde_json::Value) -> Dialog {
|
||||
serde_json::from_value(value).unwrap_or_else(|e| panic!("bad dialog json: {e}"))
|
||||
}
|
||||
|
||||
/// The text painted inside the confirm button.
|
||||
fn confirm_label(h: &Headless) -> String {
|
||||
let rect = h.app.widgets.iter().find(|(w, _)| w == "button:dialogOk").map(|(_, r)| *r).unwrap_or_else(|| panic!("no confirm button"));
|
||||
h.view
|
||||
.painted_shapes()
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
egui::epaint::Shape::Text(t) if rect.contains(t.pos + t.galley.rect.center().to_vec2()) => Some(t.galley.job.text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn select(h: &mut Headless, n: usize) {
|
||||
let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(n).collect();
|
||||
h.app.session.selection.ids = ids;
|
||||
}
|
||||
|
||||
fn text_with(h: &Headless, start: &str) -> String {
|
||||
h.painted_text().into_iter().find(|t| t.starts_with(start)).unwrap_or_else(|| panic!("no text starting {start:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_buttons_name_their_action() {
|
||||
let mut h = demo();
|
||||
h.app
|
||||
.run("photo.setMeta", json!({"ids": [h.app.session.catalog.photos().next().map(|p| p.id.0).unwrap_or(0)], "addKeywords": ["taken"]}))
|
||||
.unwrap();
|
||||
let cases: Vec<(&str, serde_json::Value, &str)> = vec![
|
||||
("new album", json!({"kind": "newAlbum", "name": "", "folder": false}), "Create"),
|
||||
("new folder", json!({"kind": "newAlbum", "name": "", "folder": true}), "Create"),
|
||||
("rename album", json!({"kind": "renameAlbum", "id": 1, "name": "A"}), "Rename"),
|
||||
("new smart album", json!({"kind": "newSmartAlbum", "name": ""}), "Create"),
|
||||
("auto-stack", json!({"kind": "autoStack", "gap": 60.0}), "Create Stacks"),
|
||||
("capture time", json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}), "Change Time"),
|
||||
("label names", json!({"kind": "labelNames", "names": [], "save_as": ""}), "Save"),
|
||||
("rename photos", json!({"kind": "rename", "template": "{name}", "start": 1}), "Rename"),
|
||||
("rename keyword", json!({"kind": "renameKeyword", "from": "a", "to": "b"}), "Rename"),
|
||||
("rename keyword onto an existing one", json!({"kind": "renameKeyword", "from": "a", "to": "taken"}), "Merge Keywords"),
|
||||
("rename person", json!({"kind": "renamePerson", "from": "Ann", "to": "Anna", "merge": false}), "Rename"),
|
||||
("merge keywords", json!({"kind": "mergeKeywords", "from": ["a"], "into": "b"}), "Merge Keywords"),
|
||||
("assisted culling", json!({"kind": "cull", "reject_below": 0.0, "pick_best": true, "burst_gap": 10.0, "similarity": 0.93}), "Cull"),
|
||||
("new preset", json!({"kind": "createPreset", "name": "", "group": ""}), "Create"),
|
||||
("copy settings", json!({"kind": "copySettings", "groups": []}), "Copy"),
|
||||
("paste settings", json!({"kind": "pasteSettings", "groups": []}), "Paste"),
|
||||
];
|
||||
for (what, value, want) in cases {
|
||||
show(&mut h, dialog(value));
|
||||
assert_eq!(confirm_label(&h), want, "{what}");
|
||||
}
|
||||
// a new smart album's rules are created; an existing one's are saved
|
||||
for (id, want) in [(None, "Create"), (Some(1), "Save")] {
|
||||
let d = Dialog::SmartRules { id, name: "A".into(), rules: Default::default(), parent: None };
|
||||
show(&mut h, d);
|
||||
assert_eq!(confirm_label(&h), want, "smart album rules {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_field_prompts_name_what_they_do() {
|
||||
let mut h = demo();
|
||||
let prompts = [
|
||||
("album.rename", "Rename"),
|
||||
("folder.rename", "Rename"),
|
||||
("folder.create", "Create"),
|
||||
("version.rename", "Rename"),
|
||||
("preset.rename", "Rename"),
|
||||
("mask.renameEffect", "Rename"),
|
||||
("mask.setEffectGroup", "Create"),
|
||||
("preset.move", "Move"),
|
||||
("app.newLibrary", "Create"),
|
||||
("keyword.saveSet", "Save"),
|
||||
("filter.savePreset", "Save"),
|
||||
("curve.savePreset", "Save"),
|
||||
("metadata.savePreset", "Save"),
|
||||
("photo.tagFromTracklog", "Add Locations"),
|
||||
];
|
||||
for (command, want) in prompts {
|
||||
let prompt = Dialog::TextPrompt {
|
||||
title: "Title".into(),
|
||||
hint: "Hint".into(),
|
||||
value: String::new(),
|
||||
command: command.into(),
|
||||
params: json!({}),
|
||||
key: "name".into(),
|
||||
};
|
||||
show(&mut h, prompt);
|
||||
assert_eq!(confirm_label(&h), want, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_confirm_says_how_many_photos() {
|
||||
let mut h = demo();
|
||||
for (n, want) in [(1, "Export 1 Photo"), (3, "Export 3 Photos")] {
|
||||
select(&mut h, n);
|
||||
h.app.run("dialog.export", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(confirm_label(&h), want);
|
||||
}
|
||||
}
|
||||
|
||||
/// With Move, a checked file whose photo is in Recently Deleted is restored, not moved: the confirm counts it apart.
|
||||
#[test]
|
||||
fn the_move_confirm_counts_restored_photos_apart_from_moved_ones() {
|
||||
let mut h = demo();
|
||||
let review = |copy: bool, move_files: bool, on_deleted: &str, checked: [bool; 3]| {
|
||||
let candidates = (0..3).map(|i| lightcraft_engine::import::ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect();
|
||||
let mut opts = crate::import::ImportDialog::new(candidates);
|
||||
opts.copy = copy;
|
||||
opts.move_files = move_files;
|
||||
opts.trashed = vec![true, false, false];
|
||||
opts.on_deleted = on_deleted.into();
|
||||
opts.checked = checked.to_vec();
|
||||
Dialog::Import { opts: Box::new(opts) }
|
||||
};
|
||||
for (copy, move_files, on_deleted, checked, want) in [
|
||||
(true, true, "restore", [true, true, true], "Move 2 Photos · Restore 1 Photo"),
|
||||
(true, true, "restore", [true, true, false], "Move 1 Photo · Restore 1 Photo"),
|
||||
(true, true, "restore", [true, false, false], "Restore 1 Photo"),
|
||||
(true, true, "restore", [false, true, true], "Move 2 Photos"),
|
||||
(true, true, "fresh", [true, true, true], "Move 3 Photos"),
|
||||
// "Leave them" still restores a file that was checked by hand: the engine's default
|
||||
(true, true, "", [true, true, true], "Move 2 Photos · Restore 1 Photo"),
|
||||
(true, false, "restore", [true, true, true], "Import 3 Photos"),
|
||||
] {
|
||||
show(&mut h, review(copy, move_files, on_deleted, checked));
|
||||
// the wide review sits over the photo grid: its text (view tab, file names) is painted inside the button's rectangle too,
|
||||
// so the label is what the rectangle's text ends with, and it names each verb as often as `want` does
|
||||
let label = confirm_label(&h);
|
||||
let said = |verb: &str| (label.matches(verb).count(), want.matches(verb).count());
|
||||
let what = format!("copy {copy}, move {move_files}, {on_deleted}, {checked:?}: {label:?}");
|
||||
assert!(label.ends_with(want), "{what}");
|
||||
assert_eq!(said("Move").0, said("Move").1, "{what}");
|
||||
assert_eq!(said("Restore").0, said("Restore").1, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unusable_model_file_only_closes() {
|
||||
let mut h = demo();
|
||||
for kind in ["denoiseModel", "faceModel"] {
|
||||
let mut d = json!({"kind": kind, "info": {"kind": "unsupported", "fileName": "x.onnx", "sizeBytes": 1}, "accepted": false});
|
||||
d["path"] = json!("x.onnx");
|
||||
show(&mut h, dialog(d));
|
||||
assert_eq!(confirm_label(&h), "Close", "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_confirm_is_the_verb_in_another_language() {
|
||||
struct Restore;
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
crate::i18n::set_language(crate::i18n::Locale::En);
|
||||
}
|
||||
}
|
||||
let _restore = Restore;
|
||||
let mut h = demo();
|
||||
h.app.ui.language = crate::i18n::Locale::De;
|
||||
show(&mut h, dialog(json!({"kind": "autoStack", "gap": 60.0})));
|
||||
assert_eq!(confirm_label(&h), "Stapel erstellen");
|
||||
show(&mut h, dialog(json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0})));
|
||||
assert_eq!(confirm_label(&h), "Zeit ändern");
|
||||
show(&mut h, dialog(json!({"kind": "renameAlbum", "id": 1, "name": "A"})));
|
||||
assert_eq!(confirm_label(&h), "Umbenennen");
|
||||
}
|
||||
|
||||
/// "N noun(s)" in `text`: the number before `noun` and whether `noun` is written in the plural.
|
||||
fn count_before(text: &str, noun: &str) -> (u64, bool) {
|
||||
let at = text.find(noun).unwrap_or_else(|| panic!("{text:?} has no {noun:?}"));
|
||||
let n = text[..at].trim_end().rsplit(' ').next().and_then(|w| w.parse().ok()).unwrap_or_else(|| panic!("no number before {noun:?} in {text:?}"));
|
||||
(n, text[at + noun.len()..].starts_with('s'))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_agree_with_their_nouns() {
|
||||
let mut h = demo();
|
||||
// contact sheet: one photo, then two
|
||||
for n in [1usize, 2] {
|
||||
select(&mut h, n);
|
||||
let options = serde_json::to_value(lightcraft_engine::contact_sheet::Options::default()).unwrap();
|
||||
show(&mut h, dialog(json!({"kind": "contactSheet", "options": options})));
|
||||
let text = text_with(&h, &format!("{n} selected photo"));
|
||||
assert_eq!(text, if n == 1 { "1 selected photo · 1 page" } else { "2 selected photos · 1 page" });
|
||||
}
|
||||
// auto-stack: a tiny gap and a huge one give different counts; each agrees with its noun
|
||||
for gap in [1.0, 86400.0] {
|
||||
show(&mut h, dialog(json!({"kind": "autoStack", "gap": gap})));
|
||||
let text = text_with(&h, "Creates ");
|
||||
let (stacks, plural) = count_before(&text, " stack");
|
||||
assert_eq!(plural, stacks != 1, "{text:?}");
|
||||
}
|
||||
// slideshow export: photos and slides
|
||||
show(
|
||||
&mut h,
|
||||
dialog(
|
||||
json!({"kind": "slideshowExport", "settings": serde_json::to_value(lightcraft_engine::slideshow_export::ExportSettings::default()).unwrap()}),
|
||||
),
|
||||
);
|
||||
let text = h.painted_text().into_iter().find(|t| t.contains("in view")).unwrap_or_default();
|
||||
let (photos, p1) = count_before(&text, " photo");
|
||||
let (slides, p2) = count_before(&text, " slide");
|
||||
assert_eq!((p1, p2), (photos != 1, slides != 1), "{text:?}");
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Esc in a text box of Settings gives the text back and leaves Settings open, and text typed in a
|
||||
//! box is saved when another tab is clicked.
|
||||
//!
|
||||
//! Before: Esc in Copyright or Creator saved what had been typed and closed the window, and text
|
||||
//! typed there was dropped when another tab was clicked.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn demo() -> Headless {
|
||||
let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()), [1200.0, 900.0], 1.0);
|
||||
// let the first renders finish (a render still running at exit can crash in GPU teardown)
|
||||
h.settle(T);
|
||||
h
|
||||
}
|
||||
|
||||
fn steps(h: &mut Headless) {
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn show(h: &mut Headless, tab: &str) {
|
||||
let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T);
|
||||
assert_eq!(r["ok"], true, "app.settings {tab}: {r}");
|
||||
h.settle(T);
|
||||
steps(h);
|
||||
}
|
||||
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), T);
|
||||
assert_eq!(r["ok"], true, "click {id}: {r}");
|
||||
steps(h);
|
||||
}
|
||||
|
||||
fn key(h: &mut Headless, key: &str) {
|
||||
assert_eq!(h.request("ui.key", json!({"key": key}), T)["ok"], true, "{key}");
|
||||
steps(h);
|
||||
}
|
||||
|
||||
fn says(h: &Headless, part: &str) -> bool {
|
||||
h.painted_text().iter().any(|text| text.contains(part))
|
||||
}
|
||||
|
||||
fn settings_open(h: &Headless, tab: &str) -> bool {
|
||||
h.app.ui.dialog == Some(Dialog::Settings { tab: tab.into() })
|
||||
}
|
||||
|
||||
fn copyright(h: &mut Headless) -> Value {
|
||||
h.app.run("library.preferences", json!({})).unwrap()["import"]["copyright"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_in_copyright_gives_the_text_back_and_leaves_settings_open() {
|
||||
let mut h = demo();
|
||||
show(&mut h, "import");
|
||||
let before = copyright(&mut h);
|
||||
// Given a name typed into Copyright
|
||||
click(&mut h, "field:settings.copyright");
|
||||
h.request("ui.text", json!({"text": "Example Name"}), T);
|
||||
steps(&mut h);
|
||||
assert!(says(&h, "Example Name"), "the typing shows");
|
||||
// When Esc is pressed
|
||||
key(&mut h, "Escape");
|
||||
// Then the name is gone again, nothing was saved, and Settings is still open
|
||||
assert!(!says(&h, "Example Name"), "Esc gives the text back");
|
||||
assert_eq!(copyright(&mut h), before, "nothing was saved");
|
||||
assert!(settings_open(&h, "import"), "Settings stays open: {:?}", h.app.ui.dialog);
|
||||
// And a second Esc, with no edit under way, closes Settings as it always did
|
||||
key(&mut h, "Escape");
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_in_copyright_saves_the_text() {
|
||||
let mut h = demo();
|
||||
show(&mut h, "import");
|
||||
click(&mut h, "field:settings.copyright");
|
||||
h.request("ui.text", json!({"text": "Example Name"}), T);
|
||||
steps(&mut h);
|
||||
key(&mut h, "Enter");
|
||||
assert_eq!(copyright(&mut h), json!("Example Name"));
|
||||
assert!(settings_open(&h, "import"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_typed_in_copyright_is_saved_when_another_tab_is_opened() {
|
||||
let mut h = demo();
|
||||
show(&mut h, "import");
|
||||
click(&mut h, "field:settings.copyright");
|
||||
h.request("ui.text", json!({"text": "Example Name"}), T);
|
||||
steps(&mut h);
|
||||
// When another tab is clicked with the text still being typed
|
||||
click(&mut h, "button:settingsTab-general");
|
||||
// Then that tab opens and the text was saved, as leaving the box any other way saves it
|
||||
assert!(settings_open(&h, "general"));
|
||||
assert_eq!(copyright(&mut h), json!("Example Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_in_an_external_editing_box_leaves_settings_open_too() {
|
||||
let mut h = demo();
|
||||
show(&mut h, "general");
|
||||
// (External Editing is far down the General page: scroll it into view)
|
||||
assert_eq!(h.request("ui.hoverWidget", json!({"id": "settings:pageRoom"}), T)["ok"], true);
|
||||
h.request("ui.scroll", json!({"dy": -3000.0}), T);
|
||||
steps(&mut h);
|
||||
let before = h.app.ui.settings.external_editor.clone();
|
||||
click(&mut h, "field:externalEditor");
|
||||
h.request("ui.text", json!({"text": "Example Editor"}), T);
|
||||
steps(&mut h);
|
||||
key(&mut h, "Escape");
|
||||
assert_eq!(h.app.ui.settings.external_editor, before, "Esc gives the text back");
|
||||
assert!(settings_open(&h, "general"), "and Settings stays open: {:?}", h.app.ui.dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn using_the_edit_menu_does_not_save_a_half_typed_text() {
|
||||
let mut h = demo();
|
||||
show(&mut h, "import");
|
||||
let before = copyright(&mut h);
|
||||
click(&mut h, "field:settings.copyright");
|
||||
h.request("ui.text", json!({"text": "Example Name"}), T);
|
||||
steps(&mut h);
|
||||
// When the field's Cut / Copy / Paste menu is opened, which takes the focus off the box
|
||||
let r = h.request("ui.clickWidget", json!({"id": "field:settings.copyright", "button": "right"}), T);
|
||||
assert_eq!(r["ok"], true, "right click: {r}");
|
||||
steps(&mut h);
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "field:settings.copyright:copy"), "the menu is open");
|
||||
// Then the text typed so far is still there, and nothing was saved
|
||||
assert!(says(&h, "Example Name"), "the typing stays in the box");
|
||||
assert_eq!(copyright(&mut h), before, "an unfinished edit is not saved");
|
||||
// And Esc closes only the menu: the edit goes on, and Return then saves it
|
||||
key(&mut h, "Escape");
|
||||
assert_eq!(copyright(&mut h), before, "closing the menu does not end the edit");
|
||||
assert!(settings_open(&h, "import"), "Esc closed the menu only");
|
||||
key(&mut h, "Enter");
|
||||
assert_eq!(copyright(&mut h), json!("Example Name"));
|
||||
}
|
||||
@@ -132,16 +132,33 @@ fn the_top_bar_imports_in_every_library() {
|
||||
#[test]
|
||||
fn the_hint_shows_the_shortcut_as_bound() {
|
||||
let mut keymap = crate::state::AppSettings::default().keymap;
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⇧I");
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⇧⌘I");
|
||||
assert_eq!(empty_library::hint(&keymap, false), "or drop photos and folders into this window · Ctrl+Shift+I");
|
||||
// a remapped shortcut is the one shown
|
||||
crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+I")).unwrap();
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⌥I");
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌥⌘I");
|
||||
// and none at all leaves the key out
|
||||
crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap();
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window");
|
||||
}
|
||||
|
||||
/// The grid's line for a view with no photos that is not the empty library names the shortcut as
|
||||
/// the menu bar lists it, in the platform's notation, and follows a rebinding.
|
||||
#[test]
|
||||
fn the_no_photos_line_lists_modifiers_in_the_menu_order() {
|
||||
let mut keymap = crate::state::AppSettings::default().keymap;
|
||||
let mac = empty_library::no_photos_body(&keymap, true);
|
||||
assert!(mac.contains("⇧⌘I") && !mac.contains("⌘⇧"), "{mac}");
|
||||
let other = empty_library::no_photos_body(&keymap, false);
|
||||
assert!(other.contains("Ctrl+Shift+I") && !other.contains("Cmd"), "{other}");
|
||||
crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+Shift+I")).unwrap();
|
||||
let rebound = empty_library::no_photos_body(&keymap, true);
|
||||
assert!(rebound.contains("⌥⇧⌘I"), "{rebound}");
|
||||
crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap();
|
||||
let unbound = empty_library::no_photos_body(&keymap, true);
|
||||
assert!(!unbound.contains('⌘') && !unbound.contains("Cmd") && !unbound.starts_with("or "), "{unbound}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_browsed_under_local_is_not_the_empty_library() {
|
||||
// its photos aren't in the library, but they are what is on screen: no import prompt
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! A one-line prompt that the engine refuses stays open with its text and says why, so the name can
|
||||
//! be fixed; a folder made on disk says it was made.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(60);
|
||||
const CREATED: &str = "Created folder “{name}” in “{parent}”";
|
||||
|
||||
/// A fresh `Shoot` folder holding `Taken` and `Other`, in a temporary folder of its own.
|
||||
fn shoot(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("lc-text-prompt-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let shoot = root.join("Shoot");
|
||||
for name in ["Taken", "Other"] {
|
||||
std::fs::create_dir_all(shoot.join(name)).unwrap();
|
||||
}
|
||||
shoot
|
||||
}
|
||||
|
||||
/// Remove the temporary folder `shoot` made, which holds `dir`.
|
||||
fn clean(dir: &std::path::Path) {
|
||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
||||
}
|
||||
|
||||
fn app() -> Headless {
|
||||
let session = lightcraft_engine::Session::new().with_fs();
|
||||
let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0);
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn prompt(command: &str, value: &str, params: serde_json::Value, key: &str) -> Dialog {
|
||||
Dialog::TextPrompt { title: "Title".into(), hint: "Name".into(), value: value.into(), command: command.into(), params, key: key.into() }
|
||||
}
|
||||
|
||||
/// Open `dialog`, press its OK button, and let the frame settle.
|
||||
fn confirm(h: &mut Headless, dialog: Dialog) {
|
||||
h.app.ui.dialog = Some(dialog);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
fn toast(h: &Headless) -> String {
|
||||
h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn assert_stays_open(h: &Headless, value: &str, reason: &str) {
|
||||
match &h.app.ui.dialog {
|
||||
Some(Dialog::TextPrompt { value: kept, .. }) => assert_eq!(kept, value, "the typed text is kept"),
|
||||
other => panic!("the prompt stays open: {other:?}"),
|
||||
}
|
||||
assert!(toast(h).contains(reason), "the reason is shown: {:?}", toast(h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_folder_whose_name_is_taken_keeps_the_prompt_open_and_says_why() {
|
||||
let dir = shoot("create-taken");
|
||||
let mut h = app();
|
||||
confirm(&mut h, prompt("folder.create", "Taken", json!({"parent": dir.to_string_lossy()}), "name"));
|
||||
assert_stays_open(&h, "Taken", "already exists");
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_to_a_taken_name_keeps_the_prompt_open_and_says_why() {
|
||||
let dir = shoot("rename-taken");
|
||||
let mut h = app();
|
||||
confirm(&mut h, prompt("folder.rename", "Taken", json!({"path": dir.join("Other").to_string_lossy()}), "name"));
|
||||
assert_stays_open(&h, "Taken", "already exists");
|
||||
assert!(dir.join("Other").is_dir(), "nothing was renamed");
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_that_keeps_the_name_closes_the_prompt_and_changes_nothing() {
|
||||
let dir = shoot("rename-same");
|
||||
let mut h = app();
|
||||
// Rename Folder opens with the folder's own name typed
|
||||
confirm(&mut h, prompt("folder.rename", "Other", json!({"path": dir.join("Other").to_string_lossy()}), "name"));
|
||||
assert_eq!(h.app.ui.dialog, None, "the prompt closes");
|
||||
assert!(toast(&h).is_empty(), "no error is shown: {:?}", toast(&h));
|
||||
assert!(dir.join("Other").is_dir(), "nothing was renamed");
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_channel_keeps_a_refused_prompt_open_too() {
|
||||
let dir = shoot("control-taken");
|
||||
let mut h = app();
|
||||
h.app.ui.dialog = Some(prompt("folder.create", "Taken", json!({"parent": dir.to_string_lossy()}), "name"));
|
||||
assert!(h.settle(SETTLE), "the prompt renders settled");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_ne!(r["ok"], true, "the name is refused: {r}");
|
||||
assert!(r.to_string().contains("already exists"), "{r}");
|
||||
match &h.app.ui.dialog {
|
||||
Some(Dialog::TextPrompt { value, .. }) => assert_eq!(value, "Taken", "the typed text is kept"),
|
||||
other => panic!("the prompt stays open: {other:?}"),
|
||||
}
|
||||
assert!(toast(&h).contains("already exists"), "the reason is on screen too, as with the button: {:?}", toast(&h));
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_channel_says_where_a_new_folder_was_made() {
|
||||
let dir = shoot("control-ok");
|
||||
let mut h = app();
|
||||
h.app.ui.dialog = Some(prompt("folder.create", "Selects", json!({"parent": dir.to_string_lossy()}), "name"));
|
||||
assert!(h.settle(SETTLE), "the prompt renders settled");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(dir.join("Selects").is_dir(), "made on disk");
|
||||
let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy());
|
||||
assert_eq!(toast(&h), CREATED.replace("{name}", "Selects").replace("{parent}", &parent));
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_that_keeps_a_name_with_spaces_at_its_ends_changes_nothing() {
|
||||
let dir = shoot("rename-padded");
|
||||
std::fs::create_dir_all(dir.join(" Pad ")).unwrap();
|
||||
let mut h = app();
|
||||
// Rename Folder opens with the folder's own name typed, spaces and all
|
||||
confirm(&mut h, prompt("folder.rename", " Pad ", json!({"path": dir.join(" Pad ").to_string_lossy()}), "name"));
|
||||
assert_eq!(h.app.ui.dialog, None, "the prompt closes");
|
||||
assert!(toast(&h).is_empty(), "no error is shown: {:?}", toast(&h));
|
||||
assert!(dir.join(" Pad ").is_dir() && !dir.join("Pad").exists(), "the folder keeps its name");
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_folder_closes_the_prompt_and_says_where_it_is() {
|
||||
let dir = shoot("create-ok");
|
||||
let mut h = app();
|
||||
confirm(&mut h, prompt("folder.create", " Selects ", json!({"parent": dir.to_string_lossy()}), "name"));
|
||||
assert!(dir.join("Selects").is_dir(), "made on disk");
|
||||
assert_eq!(h.app.ui.dialog, None);
|
||||
let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy());
|
||||
assert_eq!(toast(&h), CREATED.replace("{name}", "Selects").replace("{parent}", &parent));
|
||||
clean(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_created_folder_toast_comes_through_the_catalog() {
|
||||
let dir = shoot("create-de");
|
||||
let path = dir.join("Selects");
|
||||
let parent = lightcraft_catalog::folders::folder_label(&dir.to_string_lossy());
|
||||
// the catalog's own value (formats have no `Locale` accessor); a language without one shows the English wording
|
||||
let catalog: std::collections::BTreeMap<String, String> = serde_json::from_str(include_str!("../../locales/de-formats.json")).unwrap();
|
||||
let own = catalog.get(CREATED).filter(|text| !text.is_empty()).map_or(CREATED, String::as_str);
|
||||
let want = own.replace("{name}", "Selects").replace("{parent}", &parent);
|
||||
crate::i18n::set_language(crate::i18n::Locale::De);
|
||||
let text = crate::panels::left::created_text(&path.to_string_lossy(), &dir.to_string_lossy());
|
||||
crate::i18n::set_language(crate::i18n::Locale::En);
|
||||
assert_eq!(text, want);
|
||||
clean(&dir);
|
||||
}
|
||||
@@ -191,3 +191,169 @@ fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() {
|
||||
play(&mut h);
|
||||
assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there");
|
||||
}
|
||||
|
||||
// A dialog on the integrated title bar behaves like a macOS sheet: the backdrop leaves the bar
|
||||
// undimmed (the window buttons AppKit draws over it stay lit beside a bar that looks live), while
|
||||
// the bar's own controls stay inactive.
|
||||
|
||||
fn pixel(img: &egui::ColorImage, p: egui::Pos2) -> egui::Color32 {
|
||||
img.pixels[p.y as usize * img.size[0] + p.x as usize]
|
||||
}
|
||||
|
||||
fn with_export_dialog(mut h: Headless) -> Headless {
|
||||
h.app.run("dialog.export", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
h.settle(T);
|
||||
h
|
||||
}
|
||||
|
||||
// Scenario: the dialog's backdrop does not dim the integrated title bar
|
||||
#[test]
|
||||
fn a_dialog_leaves_the_integrated_title_bar_undimmed() {
|
||||
let mut h = integrated();
|
||||
let spot = empty_spot(&h);
|
||||
let below = egui::pos2(spot.x, widget(&h, "region:titlebar").bottom() + 60.0);
|
||||
let closed = h.snapshot(T);
|
||||
let mut h = with_export_dialog(h);
|
||||
let open = h.snapshot(T);
|
||||
assert_eq!(pixel(&open, spot), pixel(&closed, spot), "the bar keeps its color under a dialog");
|
||||
assert_ne!(pixel(&open, below), pixel(&closed, below), "the window below the bar is dimmed");
|
||||
}
|
||||
|
||||
// Scenario: a window with its native title bar dims everything, the top bar included
|
||||
#[test]
|
||||
fn a_dialog_dims_the_top_bar_without_the_integrated_title_bar() {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(Duration::from_secs(120));
|
||||
let spot = egui::pos2(20.0, 20.0);
|
||||
let closed = h.snapshot(T);
|
||||
let mut h = with_export_dialog(h);
|
||||
let open = h.snapshot(T);
|
||||
assert_ne!(pixel(&open, spot), pixel(&closed, spot), "the top bar is dimmed");
|
||||
}
|
||||
|
||||
// Scenario: the full-screen photo view draws no top bar, so a dialog opened there has no title bar band to spare: the
|
||||
// frame the view is entered on still holds the bar's region from the frame before, and must not lay a window handle
|
||||
// over the backdrop (the band that handle covers is the strip the backdrop would leave undimmed)
|
||||
#[test]
|
||||
fn a_dialog_in_the_full_screen_photo_view_has_no_title_bar_handle() {
|
||||
let mut h = integrated();
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "region:titlebar"), "the bar was drawn the frame before");
|
||||
h.app.run("view.fullScreenPreview", json!({})).unwrap();
|
||||
h.app.run("dialog.export", json!({})).unwrap();
|
||||
h.step();
|
||||
assert!(h.app.ui.fullscreen && h.app.ui.dialog.is_some());
|
||||
let handles = [0usize, 1].map(|i| egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dialog-title-bar").with(i)));
|
||||
let handle = h.view.ctx.memory(|m| handles.iter().any(|layer| m.areas().visible_last_frame(layer)));
|
||||
assert!(!handle, "no title bar handle over the photo");
|
||||
}
|
||||
|
||||
// Scenario: a press on the backdrop just below the bar, released on the bar, is not a click outside the dialog
|
||||
#[test]
|
||||
fn a_press_below_the_title_bar_released_on_it_keeps_the_dialog_open() {
|
||||
let mut h = integrated();
|
||||
h.app.ui.settings.close_dialogs_outside = true;
|
||||
let mut h = with_export_dialog(h);
|
||||
let spot = empty_spot(&h);
|
||||
let bar = widget(&h, "region:titlebar");
|
||||
let below = egui::pos2(spot.x, bar.bottom() + 1.0);
|
||||
let on_bar = egui::pos2(spot.x, bar.bottom() - 1.0);
|
||||
h.request("ui.drag", json!({"x": below.x, "y": below.y, "toX": on_bar.x, "toY": on_bar.y}), T);
|
||||
play(&mut h);
|
||||
assert!(h.app.ui.dialog.is_some(), "the release on the bar is not a click on the backdrop");
|
||||
}
|
||||
|
||||
// Scenario: a click on the undimmed bar is not a click outside the dialog
|
||||
#[test]
|
||||
fn a_click_on_the_title_bar_keeps_the_dialog_open() {
|
||||
let mut h = integrated();
|
||||
h.app.ui.settings.close_dialogs_outside = true;
|
||||
let mut h = with_export_dialog(h);
|
||||
let at = empty_spot(&h);
|
||||
h.request("ui.click", json!({"x": at.x, "y": at.y}), T);
|
||||
play(&mut h);
|
||||
assert!(h.app.ui.dialog.is_some(), "the bar is not the backdrop");
|
||||
let below = egui::pos2(at.x, widget(&h, "region:titlebar").bottom() + 60.0);
|
||||
h.request("ui.click", json!({"x": below.x, "y": below.y}), T);
|
||||
play(&mut h);
|
||||
assert!(h.app.ui.dialog.is_none(), "the backdrop below it still closes the dialog");
|
||||
}
|
||||
|
||||
// Scenario: the bar's controls take no clicks under a dialog
|
||||
#[test]
|
||||
fn the_title_bar_buttons_are_inactive_under_a_dialog() {
|
||||
let mut h = with_export_dialog(integrated());
|
||||
let left = h.app.ui.left_panel;
|
||||
let at = widget(&h, "icon:sidebar").center();
|
||||
h.request("ui.click", json!({"x": at.x, "y": at.y}), T);
|
||||
play(&mut h);
|
||||
assert_eq!(h.app.ui.left_panel, left, "the sidebar button does nothing");
|
||||
assert!(h.app.ui.dialog.is_some());
|
||||
}
|
||||
|
||||
// Scenario: dragging the empty bar still moves the window under a dialog, whether the dialog is
|
||||
// tall enough to reach into the bar (Export) or not (Copy Settings)
|
||||
#[test]
|
||||
fn dragging_the_empty_bar_moves_the_window_under_a_dialog() {
|
||||
for command in ["dialog.export", "dialog.copySettings"] {
|
||||
let mut h = integrated();
|
||||
h.app.run(command, json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
h.settle(T);
|
||||
let p = empty_spot(&h);
|
||||
h.window_commands.clear();
|
||||
h.request("ui.drag", json!({"x": p.x, "y": p.y, "toX": p.x + 120.0, "toY": p.y + 40.0}), T);
|
||||
play(&mut h);
|
||||
assert_eq!(h.window_commands, vec![ViewportCommand::StartDrag], "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: a tall dialog reaching into the bar keeps a press on itself from moving the window
|
||||
// (AppKit's `movable` is off while the pointer is over it), the bar beside it still moves it
|
||||
#[test]
|
||||
fn a_dialog_inside_the_bar_keeps_the_window_from_moving() {
|
||||
let mut h = with_export_dialog(integrated());
|
||||
let bar = widget(&h, "region:titlebar");
|
||||
let window = widget(&h, "dialog:window");
|
||||
assert!(window.top() < bar.bottom(), "the dialog reaches into the bar");
|
||||
let hover = |h: &mut Headless, p: egui::Pos2| {
|
||||
h.events.push(egui::Event::PointerMoved(p));
|
||||
play(h);
|
||||
craft_window::title_bar::window_movable(&h.view.ctx)
|
||||
};
|
||||
let spot = empty_spot(&h);
|
||||
assert!(hover(&mut h, spot), "the bar beside the dialog moves the window");
|
||||
assert!(!hover(&mut h, egui::pos2(window.center().x, bar.center().y)), "the dialog over the bar does not");
|
||||
}
|
||||
|
||||
// Scenario: with a dropdown open in the dialog, the bar's handle stays out of the way: egui lifts an area that was not
|
||||
// there the frame before above the others, so a handle drawn again as the pointer leaves the dialog would take the
|
||||
// press meant for the dropdown's item
|
||||
#[test]
|
||||
fn the_title_bar_handle_stays_below_a_dropdown_open_in_the_dialog() {
|
||||
let mut h = with_export_dialog(integrated());
|
||||
let spot = empty_spot(&h);
|
||||
let handle_at = |h: &mut Headless| {
|
||||
h.events.push(egui::Event::PointerMoved(spot));
|
||||
play(h);
|
||||
let handles = [0usize, 1].map(|i| egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dialog-title-bar").with(i)));
|
||||
h.view.ctx.memory(|m| handles.iter().any(|layer| m.areas().visible_last_frame(layer)))
|
||||
};
|
||||
assert!(handle_at(&mut h), "without a dropdown the bar beside the dialog is the handle's");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "combo:exportPreset"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
play(&mut h);
|
||||
assert!(egui::Popup::is_any_open(&h.view.ctx), "the preset dropdown is open");
|
||||
assert!(!handle_at(&mut h), "with one open, no handle sits above it");
|
||||
assert!(egui::Popup::is_any_open(&h.view.ctx), "(and the pointer leaving the dialog did not close it)");
|
||||
// once the dropdown is closed the handle is back, so the bar beside the dialog moves the window again
|
||||
h.request("ui.key", json!({"key": "Escape"}), T);
|
||||
play(&mut h);
|
||||
assert!(!egui::Popup::is_any_open(&h.view.ctx), "Escape closes the dropdown");
|
||||
assert!(h.app.ui.dialog.is_some(), "and leaves the dialog");
|
||||
assert!(handle_at(&mut h), "the handle is back");
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
// the value: click it to type an exact one, drag it sideways to scrub (over the label, so it
|
||||
// takes the click)
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() });
|
||||
crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it")));
|
||||
// the name is short ("Exposure value"); how to use it is the description the hover text below gives
|
||||
crate::widgets::announce(&value_resp, crate::i18n::tr_format!("{name} value", name = label_text.clone()));
|
||||
register(ui, format!("sliderValue:{}", spec.id), value_rect);
|
||||
// say that an exact value can be typed here (issue #534: an exact crop angle was hard to find)
|
||||
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp };
|
||||
@@ -977,12 +978,37 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Splits a tooltip that ends in its key hint ("Edit (E)", "Import Photos… (⌘⇧I)") into the label
|
||||
/// and the hint: `("Edit", Some("E"))`. A tooltip without one is all label. The Japanese and
|
||||
/// Traditional Chinese catalogs write the hint in full-width brackets without a space ("編集(E)").
|
||||
fn split_key_hint(tip: &str) -> (&str, Option<&str>) {
|
||||
let split =
|
||||
tip.strip_suffix(')').and_then(|body| body.rsplit_once(" (")).or_else(|| tip.strip_suffix(')').and_then(|body| body.rsplit_once('(')));
|
||||
let Some((label, hint)) = split else { return (tip, None) };
|
||||
let label = label.trim_end();
|
||||
if label.is_empty() || hint.is_empty() { (tip, None) } else { (label, Some(hint)) }
|
||||
}
|
||||
|
||||
/// The accessible name for an icon-only control whose tooltip is `tip`, and the one place the key
|
||||
/// hint leaves it. The name is the bare label, because a screen reader reads it every time it
|
||||
/// lands on the control; the hint goes into the description (macOS `AXHelp`, read after a pause).
|
||||
/// Sighted users keep the whole tooltip. Call it before `on_hover_text`, which would otherwise make
|
||||
/// the description the whole tooltip.
|
||||
pub(crate) fn accessible_label<'a>(resp: &egui::Response, tip: &'a str) -> &'a str {
|
||||
let (label, hint) = split_key_hint(tip);
|
||||
if let Some(hint) = hint {
|
||||
resp.ctx.accesskit_node_builder(resp.id, |node| node.set_description(hint.to_string()));
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// An icon-only button. `active` draws the selected background (tool strip).
|
||||
pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> Response {
|
||||
let tooltip = crate::i18n::tr(tooltip);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, active, tooltip));
|
||||
let name = accessible_label(&resp, tooltip);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, active, name));
|
||||
register(ui, format!("icon:{id}"), r);
|
||||
let p = ui.painter();
|
||||
let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y)));
|
||||
@@ -1043,6 +1069,34 @@ mod tests {
|
||||
assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_key_hint_leaves_the_name() {
|
||||
assert_eq!(split_key_hint("Edit (E)"), ("Edit", Some("E")));
|
||||
assert_eq!(split_key_hint("Presets (Shift+P)"), ("Presets", Some("Shift+P")));
|
||||
assert_eq!(split_key_hint("Import Photos… (⌘⇧I)"), ("Import Photos…", Some("⌘⇧I")));
|
||||
assert_eq!(split_key_hint("Filmstrip (/)"), ("Filmstrip", Some("/")));
|
||||
assert_eq!(split_key_hint("Square Grid (G toggles)"), ("Square Grid", Some("G toggles")));
|
||||
// nothing to split: no hint, an empty hint, no label before it, the hint not at the end
|
||||
for tip in ["Export", "Edit ()", "(E)", " (E)", "Edit (E) now", "Edit(E)", ""] {
|
||||
assert_eq!(split_key_hint(tip), (tip, None), "{tip:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every language's tooltip for an icon-only button splits into the bare label and its hint,
|
||||
/// whatever brackets the catalog writes it in (Japanese and Traditional Chinese use full-width ones).
|
||||
#[test]
|
||||
fn every_language_names_an_icon_button_by_its_label() {
|
||||
assert_eq!(split_key_hint("編集(E)"), ("編集", Some("E")));
|
||||
for locale in crate::i18n::Locale::ALL {
|
||||
for tip in ["Edit (E)", "Presets (Shift+P)", "Photo Grid (G)", "Filmstrip (/)"] {
|
||||
let translated = locale.tr(tip);
|
||||
let (label, hint) = split_key_hint(translated);
|
||||
assert!(hint.is_some_and(|h| !h.is_empty()), "{locale:?}: {translated:?} keeps its hint in the name");
|
||||
assert!(!label.contains(['(', ')', '(', ')']), "{locale:?}: {label:?} is the bare label");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudges_are_a_sensible_step() {
|
||||
let spec = |id, min, max, step| ControlSpec {
|
||||
@@ -1185,12 +1239,59 @@ pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str)
|
||||
ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string()));
|
||||
}
|
||||
|
||||
/// Names the part of the window `ui` draws for screen readers: a toolbar, or a landmark (VoiceOver's
|
||||
/// rotor lists the landmarks and jumps to one). Set on the node the `Ui` already has, so nothing is
|
||||
/// laid out differently. The node gets the `Ui`'s area as its bounds, so a screen reader's cursor
|
||||
/// lands on the part and not on an empty spot.
|
||||
pub(crate) fn announce_region(ui: &Ui, role: egui::accesskit::Role, name: impl Into<String>) {
|
||||
let name = name.into();
|
||||
let r = ui.max_rect();
|
||||
ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
|
||||
node.set_role(role);
|
||||
node.set_label(name);
|
||||
node.set_bounds(egui::accesskit::Rect { x0: f64::from(r.min.x), y0: f64::from(r.min.y), x1: f64::from(r.max.x), y1: f64::from(r.max.y) });
|
||||
});
|
||||
}
|
||||
|
||||
/// A hand-drawn clickable area (an icon, a tile, a row) announced as a button named `name`.
|
||||
pub(crate) fn announce(resp: &egui::Response, name: impl Into<String>) {
|
||||
let name = name.into();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), name.clone()));
|
||||
}
|
||||
|
||||
/// What a window with its own title and buttons is, to a screen reader.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Modal {
|
||||
/// A dialog with a task (Export, Settings, a name prompt) that stops the rest of the application.
|
||||
Dialog,
|
||||
/// One that interrupts to say something that needs an answer (the quit prompt, a library that
|
||||
/// couldn't be opened) and stops the rest of the application: an alert dialog (AT-SPI and
|
||||
/// Windows tell it from a dialog; macOS reads both alike).
|
||||
Alert,
|
||||
/// A dialog that leaves the rest of the application reachable (the update check's answer).
|
||||
Window,
|
||||
}
|
||||
|
||||
/// Announces the window `response` drew as a dialog (an alert dialog for [`Modal::Alert`]), and for
|
||||
/// [`Modal::Dialog`] and [`Modal::Alert`] as modal, instead of as a group that carries the title:
|
||||
/// the platform's accessibility layer then sees a dialog and that the rest of the application is
|
||||
/// blocked while it is open.
|
||||
pub(crate) fn announce_dialog(ctx: &egui::Context, response: &Response, kind: Modal) {
|
||||
ctx.accesskit_node_builder(response.id, |node| {
|
||||
node.set_role(if kind == Modal::Alert { egui::accesskit::Role::AlertDialog } else { egui::accesskit::Role::Dialog });
|
||||
if kind != Modal::Window {
|
||||
node.set_modal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Gives the dialog `response` drew the words it says as its accessible description, for an alert
|
||||
/// whose content (the task names, the error) is drawn as separate labels: a screen reader then
|
||||
/// has what the alert is about when it announces the alert, not only its title.
|
||||
pub(crate) fn describe_dialog(ctx: &egui::Context, response: &Response, text: &str) {
|
||||
ctx.accesskit_node_builder(response.id, |node| node.set_description(text));
|
||||
}
|
||||
|
||||
/// The same for one that is on or off (a toggle, a selected row or swatch).
|
||||
pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into<String>, selected: bool) {
|
||||
let name = name.into();
|
||||
|
||||
@@ -133,12 +133,13 @@ fn every_interactive_node_has_a_name_in_every_view_and_tool() {
|
||||
}
|
||||
h.run_steps(6);
|
||||
problems.extend(audit(&h, dialog));
|
||||
let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count();
|
||||
let is_window = |role| matches!(role, Role::Window | Role::Dialog | Role::AlertDialog);
|
||||
let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| is_window(n.role())).count();
|
||||
let untitled = h
|
||||
.root()
|
||||
.children_recursive()
|
||||
.map(|n| n.accesskit_node())
|
||||
.filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty())
|
||||
.filter(|n| is_window(n.role()) && n.label().unwrap_or_default().trim().is_empty())
|
||||
.count();
|
||||
if untitled > 0 {
|
||||
problems.push(format!("{dialog}: {untitled} of {windows} windows have no title"));
|
||||
@@ -234,7 +235,7 @@ fn tab_walks_the_right_panels_top_to_bottom() {
|
||||
.root()
|
||||
.children_recursive()
|
||||
.map(|n| n.accesskit_node())
|
||||
.find(|n| n.label().as_deref() == Some("Edit (E)"))
|
||||
.find(|n| n.label().as_deref() == Some("Edit") && n.description().as_deref() == Some("E"))
|
||||
.and_then(|n| n.bounding_box())
|
||||
.map(|b| b.x0 as f32)
|
||||
.unwrap_or(f32::INFINITY);
|
||||
@@ -259,3 +260,73 @@ fn tab_walks_the_right_panels_top_to_bottom() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A slider's value field is "<slider> value" (the instruction to click, type or drag is its
|
||||
/// description, not part of the name VoiceOver reads on every visit).
|
||||
#[test]
|
||||
fn a_slider_value_field_is_named_value_and_describes_how_to_use_it() {
|
||||
const INSTRUCTION: &str = "Click to type a value, drag to change it";
|
||||
let h = harness(ViewMode::Detail, RightPanel::Edit);
|
||||
let buttons: Vec<_> = h.root().children_recursive().filter(|n| n.accesskit_node().role() == Role::Button).collect();
|
||||
let names: Vec<String> = buttons.iter().filter_map(|n| n.accesskit_node().label()).collect();
|
||||
let field = buttons
|
||||
.iter()
|
||||
.find(|n| n.accesskit_node().label().as_deref() == Some("Exposure value"))
|
||||
.unwrap_or_else(|| panic!("a value field named \"Exposure value\": {names:?}"));
|
||||
assert_eq!(field.accesskit_node().description().as_deref(), Some(INSTRUCTION), "the instruction is its description");
|
||||
assert!(!names.iter().any(|n| n.contains(INSTRUCTION)), "no name carries the instruction: {names:?}");
|
||||
}
|
||||
|
||||
/// Whether a button named `name` has `help` as its accessible description (the menu bar has an
|
||||
/// "Edit" menu too, so the name alone doesn't pick the tool-strip button).
|
||||
fn button_described(h: &Harness<'_, LightcraftApp>, name: &str, help: &str) -> bool {
|
||||
h.root()
|
||||
.children_recursive()
|
||||
.map(|n| n.accesskit_node())
|
||||
.any(|n| n.role() == Role::Button && n.label().as_deref() == Some(name) && n.description().as_deref() == Some(help))
|
||||
}
|
||||
|
||||
/// A screen reader reads a control's name every time it lands on it: the shortcut belongs in the
|
||||
/// description (macOS AXHelp, read after a pause), not in the name.
|
||||
#[test]
|
||||
fn tool_strip_and_toolbar_buttons_are_named_by_label_with_the_shortcut_as_description() {
|
||||
let h = harness(ViewMode::Detail, RightPanel::Edit);
|
||||
for (name, help) in [
|
||||
("Presets", "Shift+P"),
|
||||
("Edit", "E"),
|
||||
("Crop & Rotate", "C"),
|
||||
("Masking", "M"),
|
||||
("Versions", "Shift+V"),
|
||||
("History & Activity", "Y"),
|
||||
("Keywords", "K"),
|
||||
("Info", "I"),
|
||||
("Photo Grid", "G"),
|
||||
("Square Grid", "G toggles"),
|
||||
("Detail", "D"),
|
||||
("Compare", "Shift+C"),
|
||||
("Survey", "N"),
|
||||
("Before/After", "Y"),
|
||||
("Filmstrip", "/"),
|
||||
] {
|
||||
assert!(button_described(&h, name, help), "a button named {name:?} describing {help:?}");
|
||||
}
|
||||
let leaked: Vec<String> = h
|
||||
.root()
|
||||
.children_recursive()
|
||||
.map(|n| n.accesskit_node())
|
||||
.filter(|n| n.role() == Role::Button)
|
||||
.filter_map(|n| n.label())
|
||||
.filter(|l| ["Edit (E)", "Presets (Shift+P)", "Photo Grid (G)", "Filmstrip (/)"].contains(&l.as_str()))
|
||||
.collect();
|
||||
assert!(leaked.is_empty(), "shortcuts left in names: {leaked:?}");
|
||||
}
|
||||
|
||||
/// The top bar's Import button carries the key it is bound to now, written for this platform.
|
||||
#[test]
|
||||
fn import_button_describes_its_shortcut_as_this_platform_writes_it() {
|
||||
let h = harness(ViewMode::PhotoGrid, RightPanel::Edit);
|
||||
let mac = h.ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let sc = lightcraft_ui_egui::shortcuts::shortcut_of(&h.state().ui.settings.keymap, "file.addPhotos").expect("Import Photos… has a shortcut");
|
||||
let help = lightcraft_ui_egui::menubar::shortcut_text(sc, mac);
|
||||
assert!(button_described(&h, "Import Photos…", &help), "a button named \"Import Photos…\" describing {help:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! An open dialog is exposed to a screen reader as a dialog (a modal one, an alert dialog for the
|
||||
//! prompts), not as a group that happens to carry the dialog's title.
|
||||
use egui::accesskit::Role;
|
||||
use egui::vec2;
|
||||
use egui_kittest::{Harness, kittest::NodeT};
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services};
|
||||
|
||||
fn harness() -> Harness<'static, LightcraftApp> {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state(
|
||||
|ui, app: &mut LightcraftApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(10);
|
||||
h
|
||||
}
|
||||
|
||||
/// `(role, title, modal)` of every window-like node in the tree.
|
||||
fn windows(h: &Harness<'_, LightcraftApp>) -> Vec<(Role, String, bool)> {
|
||||
h.root()
|
||||
.children_recursive()
|
||||
.map(|n| n.accesskit_node())
|
||||
.filter(|n| matches!(n.role(), Role::Window | Role::Dialog | Role::AlertDialog))
|
||||
.map(|n| (n.role(), n.label().unwrap_or_default(), n.data().is_modal()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_dialog_is_a_modal_dialog_with_its_title() {
|
||||
for command in ["dialog.export", "dialog.rename", "dialog.newAlbum", "dialog.createPreset", "dialog.contactSheet", "dialog.allMetadata"] {
|
||||
let mut h = harness();
|
||||
h.state_mut().run(command, serde_json::json!({})).unwrap_or_else(|e| panic!("{command}: {e}"));
|
||||
h.run_steps(6);
|
||||
let found = windows(&h);
|
||||
assert!(!found.is_empty(), "{command}: the dialog is in the tree");
|
||||
for (role, title, modal) in found {
|
||||
assert_eq!(role, Role::Dialog, "{command}: {title:?} is announced as a dialog");
|
||||
assert!(!title.trim().is_empty(), "{command}: the dialog has its title");
|
||||
assert!(modal, "{command}: {title:?} is modal, so the rest of the window is not read");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_is_a_dialog_too() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("app.settings", serde_json::json!({})).expect("Settings opens");
|
||||
h.run_steps(6);
|
||||
let found = windows(&h);
|
||||
assert!(found.iter().any(|(role, title, _)| *role == Role::Dialog && title.as_str() == "Settings"), "{found:?}");
|
||||
assert!(found.iter().all(|(role, ..)| *role != Role::Window), "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quit_prompt_and_a_settings_notice_are_modal_alert_dialogs() {
|
||||
let mut h = harness();
|
||||
h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into()));
|
||||
h.run_steps(6);
|
||||
let found = windows(&h);
|
||||
assert!(!found.is_empty(), "the quit prompt is in the tree");
|
||||
for (role, title, modal) in found {
|
||||
assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} interrupts with a question: a modal alert dialog");
|
||||
}
|
||||
let mut h = harness();
|
||||
h.state_mut().notices.push("Settings could not be saved.".into());
|
||||
h.run_steps(6);
|
||||
let found = windows(&h);
|
||||
assert!(!found.is_empty(), "the notice is in the tree");
|
||||
for (role, title, modal) in found {
|
||||
assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_that_could_not_be_opened_is_a_modal_alert_dialog() {
|
||||
let mut h = harness();
|
||||
h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here."));
|
||||
h.run_steps(6);
|
||||
let found = windows(&h);
|
||||
assert!(!found.is_empty(), "the library problem is in the tree");
|
||||
for (role, title, modal) in found {
|
||||
assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog");
|
||||
}
|
||||
}
|
||||
|
||||
/// The description of every alert dialog in the tree.
|
||||
fn alert_descriptions(h: &Harness<'_, LightcraftApp>) -> Vec<String> {
|
||||
h.root()
|
||||
.children_recursive()
|
||||
.map(|n| n.accesskit_node())
|
||||
.filter(|n| n.role() == Role::AlertDialog)
|
||||
.map(|n| n.data().description().unwrap_or_default().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_carries_what_it_says_as_its_description() {
|
||||
let mut h = harness();
|
||||
h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into()));
|
||||
h.run_steps(6);
|
||||
let said = alert_descriptions(&h);
|
||||
assert!(said.iter().any(|d| d.contains("1 task is still running: Import")), "the quit prompt names the task: {said:?}");
|
||||
let mut h = harness();
|
||||
h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here."));
|
||||
h.run_steps(6);
|
||||
let said = alert_descriptions(&h);
|
||||
assert!(
|
||||
said.iter().any(|d| d.contains("/no/such/library") && d.contains("No catalog here.") && d.contains("untouched")),
|
||||
"the library alert has its path, error and explanation: {said:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! The window's parts are named for screen readers: the three bars are toolbars, and the sidebar, the
|
||||
//! photos, the tool panel and the filmstrip are landmarks, so VoiceOver's rotor lists them and a
|
||||
//! person can jump to one instead of walking every control.
|
||||
use egui::accesskit::Role;
|
||||
use egui::vec2;
|
||||
use egui_kittest::{Harness, kittest::NodeT};
|
||||
use lightcraft_ui_egui::{
|
||||
LightcraftApp, Services,
|
||||
state::{RightPanel, ViewMode},
|
||||
};
|
||||
|
||||
fn harness(view: ViewMode) -> Harness<'static, LightcraftApp> {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
app.ui.left_panel = true;
|
||||
app.ui.view = view;
|
||||
app.ui.right = RightPanel::Edit;
|
||||
let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state(
|
||||
|ui, app: &mut LightcraftApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(10);
|
||||
h
|
||||
}
|
||||
|
||||
/// The area each node with this role and this name covers (`None`: it reports none).
|
||||
fn areas(h: &Harness<'_, LightcraftApp>, role: Role, name: &str) -> Vec<Option<egui::accesskit::Rect>> {
|
||||
let nodes = h.root().children_recursive().map(|n| n.accesskit_node());
|
||||
nodes.filter(|n| n.role() == role && n.label().as_deref() == Some(name)).map(|n| n.bounding_box()).collect()
|
||||
}
|
||||
|
||||
/// How many nodes have this role and this name.
|
||||
fn count(h: &Harness<'_, LightcraftApp>, role: Role, name: &str) -> usize {
|
||||
areas(h, role, name).len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bars_are_toolbars_and_the_panels_are_landmarks_in_detail() {
|
||||
let h = harness(ViewMode::Detail);
|
||||
for (role, name) in [
|
||||
(Role::Toolbar, "Top bar"),
|
||||
(Role::Toolbar, "Tool strip"),
|
||||
(Role::Toolbar, "Bottom bar"),
|
||||
(Role::Navigation, "Library"),
|
||||
(Role::Main, "Photos"),
|
||||
(Role::Complementary, "Tools"),
|
||||
(Role::Region, "Filmstrip"),
|
||||
] {
|
||||
assert_eq!(count(&h, role, name), 1, "one {role:?} named {name:?}");
|
||||
let area = areas(&h, role, name).into_iter().next().flatten();
|
||||
assert!(area.is_some_and(|b| b.width() > 0.0 && b.height() > 0.0), "{name:?} reports the area it covers: {area:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_is_the_main_landmark_in_the_photo_grid() {
|
||||
let h = harness(ViewMode::PhotoGrid);
|
||||
assert_eq!(count(&h, Role::Main, "Photos"), 1);
|
||||
assert_eq!(count(&h, Role::Region, "Filmstrip"), 0, "the grid has no filmstrip");
|
||||
}
|
||||
|
||||
/// A filter that hides every photo leaves the filmstrip drawn with its reason: it stays a landmark
|
||||
/// and the reason is text a screen reader reads, not paint.
|
||||
#[test]
|
||||
fn an_empty_filmstrip_is_still_a_landmark_and_says_why() {
|
||||
let mut session = lightcraft_engine::Session::with_demo();
|
||||
session.execute("library.filter", &serde_json::json!({"keyword": "no-such-keyword"})).expect("the filter is set");
|
||||
assert!(session.visible_cloned().is_empty(), "the filter hides every photo");
|
||||
let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
||||
app.ui.left_panel = true;
|
||||
app.ui.view = ViewMode::Detail;
|
||||
app.ui.right = RightPanel::Edit;
|
||||
let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state(
|
||||
|ui, app: &mut LightcraftApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(10);
|
||||
assert_eq!(count(&h, Role::Region, "Filmstrip"), 1, "the empty strip is still a landmark");
|
||||
let said = h
|
||||
.root()
|
||||
.children_recursive()
|
||||
.filter_map(|n| n.accesskit_node().value().or_else(|| n.accesskit_node().label()))
|
||||
.any(|t| t.starts_with("No photos match the filters"));
|
||||
assert!(said, "the reason is read, not only painted");
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
//! `tests/shutdown.rs` stays a binary of its own: it closes the GPU for the whole process.
|
||||
|
||||
mod accessibility;
|
||||
mod dialog_role;
|
||||
mod docking_workflows;
|
||||
mod grid_names;
|
||||
mod photo_name;
|
||||
mod sidebar_names;
|
||||
mod thumb_size_slider;
|
||||
mod local_places;
|
||||
mod landmarks;
|
||||
|
||||
@@ -130,7 +130,7 @@ new empty library first: hold ⌥ while opening Photos ▸ Create New).
|
||||
2. Open it from the Finder, or with `open target/macos-package/LightCraft.app --args --demo` (not by running
|
||||
the binary in a terminal, which makes the terminal the app macOS asks about).
|
||||
3. Select three photos, choose File ▸ Export…, set a folder (e.g. `~/Pictures/LightCraft Exports`), tick **Add
|
||||
to Apple Photos**, type the album `LightCraft test "1"` and press OK.
|
||||
to Apple Photos**, type the album `LightCraft test "1"` and press Export.
|
||||
4. macOS asks whether **LightCraft** may control Photos, showing the usage text from `Info.plist`: allow it.
|
||||
Expected: the toast reads "Exported 3 of 3 photos · 3 added to Apple Photos album “LightCraft test "1"”",
|
||||
and a top-level album of that name holds the three images.
|
||||
|
||||
@@ -54,6 +54,8 @@ Vendor RGB WB multipliers alone do **not** identify an absolute illuminant witho
|
||||
|
||||
Canon CR2s list their presets by name in the maker note's `ColorData` (`0x4001`), each with Canon's colour temperature for it: for versions -4 and 13–15 the curve is built the same way from the Daylight, Shade, Cloudy, Tungsten and Flash rows (sorted by temperature; Fluorescent, a green light off the locus, and the Kelvin row, which holds the dial's last setting, are left out). Only the PowerShot G1 X Mark III is enabled, checked on a reporter's nine photos, one per white-balance setting: the as-shot levels read 4160 / 4580 / 5995 / 5215 / 2816 / 3703 / 7162 / 5368 / 4813 K (Auto, Daylight, Shade, Cloudy, Tungsten, Fluorescent at tint +31, Flash, Underwater, Kelvin 5500) where the camera's own `ColorTempAsShot` says 4306 / 4703 / 6005 / 5272 / 2770 / 3533 / 7100 / 5347 / 4925 K. Those photos were shot with the white-balance shift 8 steps (of 9) towards blue (`WBShiftAB` −8), which is why a Daylight shot reads about 4600 K and not the Daylight preset's 5651 K: the levels the camera applied are bluer than the preset's, and the camera's own number agrees.
|
||||
|
||||
**Kelvin through a Fujifilm body's stored neutral pair (issue #730).** Fujifilm RAFs carry, in tag `0xf00c` of the raw IFD (`WB_GRBLevelsStandard`; tag number from `exiftool -v3` run as a black-box tool on the files), the camera's own neutral multipliers for Illuminant A (2856 K) and D65 (6504 K): `G R B 17 G R B 21`. For the X-Trans III bodies without measured matrices (`lightcraft_raw::kelvin_presets`: X-T20, X-T2, X-Pro2, X-E3, X-H1, X100F) they take the matrices' place for the Kelvin scale only, with the same decision, adaptation, pixel-identical upgrade and `Op::SetAsShotWb` as above. Two whites fix a camera's response only on the line between them, so nothing but the colorimetry of the two lights places a white off it (`KelvinPresets::from_neutral_pair`): in log red/green and blue/green mired is linear along the line, extended past both ends; the isotherms are perpendicular to it; the tint is the Planckian locus's own CIE 1960 spacing per unit length, plus the tint the two lights have (A on the locus, D65 about +9.5). No other camera's data is used, and the colour look stays the file's JPEG fit. Scored (never fitted) against the three bodies in the spectral table that have a spectral response (X-T3, X-T4, GFX 100), on a grid of 3200 to 9000 K and tints of ±20, this plain line reads whites to 6.8 mired rms (21 at worst) and 3.0 tint rms (8 at worst); beyond that grid, to 2500 to 12000 K, 11 mired rms. That is the accepted accuracy of a two-point Kelvin scale: As Shot and any white near the line are right, a strongly green or magenta light is read a few mired and a few tint off. Reading `DSCF2947.raf` (X-T20): 6193 K / +16 (a Lightroom reading of the same file, 6050 K / +22, is a sanity check, not an input).
|
||||
|
||||
What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap).
|
||||
|
||||
Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust.
|
||||
@@ -127,11 +129,33 @@ still has lens corrections removed, so the pipeline applies them once through En
|
||||
Corrections. Developing this proxy requires a full-resolution demosaic; files without such
|
||||
opcodes keep their binned proxy.
|
||||
|
||||
## Embedded vignetting correction (Fujifilm RAF)
|
||||
|
||||
The RAF decoder turns the maker note's vignetting data into a `FixVignetteRadial` correction, and the render applies
|
||||
it (Enable Profile Corrections). The camera applied the same correction to its JPEG. Every per-file fit
|
||||
(`fit_preview`, `monochrome_look`, the near-black tone fallback) therefore takes its sensor proxy through
|
||||
`look_proxies`, which multiplies it by that gain as `fit_dng_tone` already did (`correct_vignetting`). Before
|
||||
`LOOK_VERSION` 13 the RAF fit saw the uncorrected sensor: the tone curve learned the JPEG's corrected brightness, and
|
||||
the render added the correction again. Lens-database corrections (`LIGHTCRAFT_LENSFUN_DIR`) reach the fit the same way.
|
||||
`proxies` applies the gain before it moves the sensor into the JPEG's lens geometry, so each pixel gets the gain of the
|
||||
position it samples, as in the render. The choice between the warped and the plain sensor (`alignment`) leaves out
|
||||
the pixels the warp finds no sensor for. It used to read them as black, a false edge along the border that made the
|
||||
warped candidate lose on some wide-angle shots with barrel distortion; neither change moved ΔE00 by more than 0.04 on
|
||||
the photos below.
|
||||
|
||||
On 21 X-S10 photos the render was up to 0.3 EV brighter than the camera JPEG in the midtones (a wide-open blue-hour
|
||||
shot) and up to 0.6 EV in the darkest 3 % of the pixels (ISO 12800 indoors). On 60 other X-S10 photos, picked at
|
||||
random from one photographer's library and rendered without their sidecars, mean ΔE00 against the camera JPEG went
|
||||
from 2.71 to 2.55: 32 improved by more than 0.1, none got worse by more than 0.05. The median luminance error per
|
||||
quantile is now within ±0.04 EV, except +0.09 EV in the darkest 3 %. That rest comes from fitting on a 96 px proxy:
|
||||
each proxy pixel averages a shadow's texture before the curve, while the render applies the curve per pixel. A fit
|
||||
on a 4× finer proxy roughly halves it in a simulation, but the acceptance gates were set at 96 px.
|
||||
|
||||
## Camera profiles (pooled per model)
|
||||
|
||||
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
|
||||
|
||||
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
|
||||
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below); ILCE-7RM5, 220 photos (Standard creative style); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
|
||||
|
||||
|
||||
### Sony ILCE-7CR
|
||||
|
||||
@@ -127,9 +127,19 @@ raws with colour matrices — in the file (DNG) or measured for the camera model
|
||||
#730) — and on raws whose white-balance presets give the scale (`lightcraft_raw::kelvin_presets`), where As Shot
|
||||
reads the file's as-shot white; rendered files and raws without matrices use a relative scale
|
||||
whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw
|
||||
says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint`
|
||||
**without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean
|
||||
Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use `develop.set` / `develop.wb`.
|
||||
says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge`, to a preset's `wb`, or to
|
||||
`lightcraft-cli render --settings` and which sets `temp` / `tint` **without** `scale` keeps the relative meaning it
|
||||
always had (6500 = as shot) on every raw, so agents that mean Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use
|
||||
`develop.set` / `develop.wb`. A partial that names only one of `temp` / `tint` is resolved against the photo
|
||||
(issue #2910): the photo's white balance is first brought onto the partial's scale with the photo's own as-shot
|
||||
white, so the field the partial does not name keeps the look it had instead of being re-read on the partial's scale,
|
||||
and the stored result is back on the scale the photo is edited on (`"scale": "kelvin"` on a Kelvin raw). The same
|
||||
holds for `preset.apply` when the preset names only one of the two numbers; a preset written by the app, or imported
|
||||
from crs / XMP, names `mode`, `temp`, `tint` and `scale` together and behaves exactly as before. A photo whose white
|
||||
balance mode answers its own white — `As Shot`, or a named preset on a raw edited in Kelvin — has that white written
|
||||
out as `Custom` values first, because a number merged into such a mode would otherwise be stored where no render
|
||||
reads it (the panel would show 5800 while the photo stayed at as shot); a partial that names a `mode` itself stays
|
||||
authoritative. Partials naming both fields mean the same as before.
|
||||
|
||||
Image navigation is also available directly as the UI command `view.navigate`, with
|
||||
`{zoom?: "fit" | "fill" | {"percent": number}, pan?: [x, y]}`. Percentage zoom accepts fractional
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -177,7 +177,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
|
||||
| `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were |
|
||||
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
|
||||
| `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted). White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw (see [control-protocol.md](control-protocol.md)) |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted). White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw, and one that names only `temp` or only `tint` resolves the field it leaves out against the photo, so that field keeps its look; on a photo still on As Shot (or on a named white balance preset, on a raw with matrices) the white it really renders is written out as Custom values first, so the number named takes effect instead of being stored unread (#2910, and its review); `apply_preset` resolves a preset that names one number the same way (see [control-protocol.md](control-protocol.md)) |
|
||||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+7
-4
@@ -22,7 +22,7 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md),
|
||||
| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured |
|
||||
| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence |
|
||||
| …of which on a body we have verified | **~85%** | estimated, same weights |
|
||||
| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) |
|
||||
| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **9 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) |
|
||||
| Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured |
|
||||
| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) |
|
||||
|
||||
@@ -214,18 +214,19 @@ sample files. A pointer for contributors, not legal advice.
|
||||
|
||||
| Item | Adobe | LightCraft |
|
||||
|---|---|---|
|
||||
| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 Fujifilm bodies take the X-T3's (X-T30, X-T30 II, X-Pro3, X100V, X-E4, X-M5) and 2 take the X-T4's (X-S10, X-S20)); else a neutral matrix |
|
||||
| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**9 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 Fujifilm bodies take the X-T3's (X-T30, X-T30 II, X-Pro3, X100V, X-E4, X-M5) and 2 take the X-T4's (X-S10, X-S20)); else a neutral matrix |
|
||||
| Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) |
|
||||
| Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) |
|
||||
| DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) |
|
||||
| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`); other raws keep the relative scale (#296) |
|
||||
| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`), or the neutral pair a Fujifilm X-Trans III RAF stores (6 bodies, #730); other raws keep the relative scale (#296) |
|
||||
|
||||
**How far the default rendering is from Adobe's** (CIE ΔE; measured where stated):
|
||||
|
||||
- Against the camera JPEG (what the fit targets): the Sony bulk check (#535) accepted fits on 81 of 87
|
||||
bodies, median ΔE 5.1, ΔL\* +0.2; rejected fits fall back to a neutral matrix at median ΔE 18.5,
|
||||
ΔL\* −11, chroma ×0.39. RW2 140 of 174 files accepted, median 5.0. NEF 13 of 13, 2.9–7.8. RAF 23 of
|
||||
24, pooled X-H2S 1.62 / X-T4 2.34. SRW 2.2–7.6.
|
||||
24, pooled X-H2S 1.62 / X-T4 2.34 (measured before `LOOK_VERSION` 13 stopped applying the embedded vignetting
|
||||
correction twice); X-S10, 60 photos not used to find that bug, mean ΔE00 2.71 → 2.55 with it fixed. SRW 2.2–7.6.
|
||||
- Against Lightroom itself (few): Sony DRO median ΔE76 7.5 → 3.3; Olympus E-1 vs Lightroom Classic
|
||||
ΔE00 11.25 → 8.30; ProRAW with its gain map ΔE00 19 (why the map is off). No measurement against
|
||||
Adobe Standard across models exists.
|
||||
@@ -296,6 +297,7 @@ Beyond decoding:
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | patch | Bundled camera profile count 5 → 9 (Nikon Z 6II, Z 8 and D5300, ILCE-7RM5); counts only |
|
||||
| 2026-10-23 | minor | Lens profiles: Sony ILCE-6700 (APS-C) in-file distortion through the ILCE-7RM6 conversion, knots spanning its own crop; lens score unchanged (~10%: one more body) |
|
||||
| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback |
|
||||
| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) |
|
||||
@@ -329,3 +331,4 @@ Beyond decoding:
|
||||
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
|
||||
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
|
||||
| 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) |
|
||||
| 2026-10-30 | patch | RAF camera look fitted on the vignetting-corrected sensor (`LOOK_VERSION` 13): renders were up to 0.3 EV too bright in midtones and 0.6 EV in shadows; X-S10 mean ΔE00 vs the camera JPEG 2.71 → 2.55 on 60 photos |
|
||||
|
||||
@@ -68,7 +68,7 @@ from the checklist sections and the deep-dive documents.
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries |
|
||||
| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF |
|
||||
| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 5 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) |
|
||||
| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 9 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) |
|
||||
| Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye |
|
||||
| Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete |
|
||||
| Masking | 8% | 67% | 45% | 60–110 h | checklist K 67.4%; AI masks heuristic or SAM 3 without mirror |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user