Merge pull request #2689 from francoisgn/round3a-integration

Round 3a integration: 42 PRs of #2580, built and tested locally
This commit is contained in:
aiongg
2026-10-24 09:39:25 -04:00
committed by GitHub
178 changed files with 13103 additions and 1102 deletions
+69
View File
@@ -0,0 +1,69 @@
<!--
Title: start with the checklist id or the issue, as the commits do:
`LR-IMP-FORMATS: folder import finds Sony SR2 and SRF files`, or `Issue #123: …` when there is no checklist row.
One change per pull request. Unrelated fixes and clean-ups (clippy, formatting, a second feature) go in their own:
small ones land fast, bundles get sent back to be split. The rules are in AGENTS.md.
Delete the sections and lines that don't apply, and these comments. A translation or a one-line fix needs only the
first three sections.
-->
Fixes #
<!-- `Part of #123` when this does only part of the issue. Built on another open pull request? Say so here
(`Stacked on #123`) and name the commits that are this one's own. -->
## Problem
<!-- What is wrong or missing, as a person using the app meets it. For a bug: the cause. -->
## Change
<!-- What you changed and where. -->
**Not in this PR:** <!-- what you left out on purpose, and known limits -->
## How it was checked
- [ ] `cargo xtask ci` passes on this branch, rebased on current `main`
- [ ] A test that fails without this change (for a crash fix: a regression test that panicked before it)
- [ ] User-visible change: ran the app and looked at the result
<!-- Numbers, when it changes rendering, decoding or speed: before and after, on more than one photo or camera
(ΔE against the reference, `cargo xtask bench`, `/usr/bin/time`, memory). Name the files or corpus entries used. -->
## Screenshots
<!-- For a visible change: before and after, labelled. Never a screenshot of an Adobe app. -->
## Merging
<!-- Pull requests land in batches, merged together. This is what the person merging can't see in the diff. -->
- **Overlap:** <!-- open pull requests that fix the same issue or change the same functions, by number, and how
this one differs. Search the open pull requests for the issue number and checklist id first. "none found" is fine. -->
- **Existing edits and libraries:** <!-- does any photo already edited render differently, or any saved setting,
preset, sort order or default change? Then it goes behind a new process version (docs/process-versions.md) or is a
setting that is off by default, with a line in docs/whats-new.md. "unchanged" is fine. -->
- **Shared numbers taken:** <!-- catalog format `VERSION` (crates/catalog/src/journal.rs), `RENDER_CACHE_VERSION`,
`LOOK_VERSION`, process version: the number this takes. If another pull request lands with it first, take one
past main's when you rebase. -->
- **Where it came from:** <!-- for a decoder, camera or lens data, or fitted constants: the public spec, your own
measurements, or observed output, and the files measured. Never GPL / LGPL code (dcraw, LibRaw, rawspeed, darktable,
RawTherapee, lensfun …), text derived from it, or Adobe profiles. -->
- **New dependencies, `unsafe`, build scripts, network access, spawned processes, deleted files, CI or `xtask` gate
changes:** <!-- each with its reason, or "none". Dependencies are pure Rust and use `workspace = true`; actions are
pinned by SHA; downloads are checked against a sha256; nothing leaves the machine unless the person asked. -->
## Checklist
- [ ] No new panic path in non-test code: no `unwrap` / `expect` / `panic!`, no indexing or unchecked arithmetic on a
number from a file, a person or an agent (`get`, `checked_mul`, NaN and char boundaries handled)
- [ ] New text on screen goes through `tr()` and has its key in `crates/ui-egui/locales/*.json`; a new shortcut is
free in `docs/library-shortcuts.md`
- [ ] The feature's rows in `docs/parity-checklist.md` and its entry in `docs/gaps.md` are updated, honestly (🟡 when
it is partial), in the same commit
- [ ] New code has a caller: no command, module or setting that nothing reaches yet
- [ ] Every new image, icon or other asset has its entry in `assets/ATTRIBUTION.md`; no font or media files added
- [ ] Ready to merge (otherwise open it as a draft)
+4 -1
View File
@@ -68,7 +68,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Quality gates** before every commit: `cargo xtask ci --quick` (fmt, clippy -D warnings, the changed packages'
tests, layers, assets, and heif / parity / wasm when touched). GitHub runs the full `cargo xtask ci` on every PR
(`.github/workflows/ci.yml`); run the full one locally before merging branches (below) or when `--quick` can't see
the risk. One `ci` runs at a time per machine; others wait their turn (`xtask/src/ci_lock.rs`).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
Generated
+4 -12
View File
@@ -3356,7 +3356,7 @@ version = "0.13.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
"quick-xml 0.38.4",
"quick-xml",
]
[[package]]
@@ -3394,7 +3394,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
"proptest",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_json",
]
@@ -3403,6 +3403,7 @@ dependencies = [
name = "lightcraft-pipeline"
version = "0.13.0"
dependencies = [
"dhat",
"image",
"lightcraft-color",
"lightcraft-develop",
@@ -4848,15 +4849,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -6347,7 +6339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
+2 -2
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
@@ -60,7 +60,7 @@ libc = "0.2" # dev-only: process CPU time in benches
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds
# formats
quick-xml = "0.38"
quick-xml = "0.41"
miniz_oxide = "0.8"
# codecs
+9
View File
@@ -405,8 +405,17 @@ cargo run --release -p lightcraft -- --control 7980 # with the automation ch
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM
cargo xtask ci --quick # only what your branch's changes can break (minutes)
```
`cargo xtask ci --quick` compares with the merge base of `origin/main` (`--base REF` for another)
and runs fmt, clippy, the tests of the changed packages, layers and assets, plus HEIF, parity and
the wasm check when the changes touch them; a change to `Cargo.toml`, `Cargo.lock` or the lint
config tests everything. GitHub runs the full `cargo xtask ci` on every pull request
(`.github/workflows/ci.yml`). Only one `ci` runs at a time per machine: a second one, from another
worktree say, waits for the first and names it, since several at once take longer in total than in
turn. `LIGHTCRAFT_CI_SLOTS=2` allows two; `LIGHTCRAFT_CI_LOCK=off` skips the lock.
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
available when it starts (at most one per CPU; 4 if memory can't be read), so an
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
+4 -4
View File
@@ -298,10 +298,10 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md)):
- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
[docs/parity-checklist.md](docs/parity-checklist.md)。
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
@@ -309,7 +309,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#upcoming)。
| 领域 | 状态 |
|---|---|
@@ -333,7 +333,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#upcoming)) |
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
+7 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -114,6 +114,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- 2026-10-22: Print page setup and PDF output gain the Japanese photo and postcard papers — L判
(89 × 127 mm), 2L判 (127 × 178), KG判 (102 × 152), はがき (100 × 148), 往復はがき (148 × 200),
六切 (203 × 254), 四切 (254 × 305) and 名刺 (55 × 91) — at their exact metric sizes. The Print dialog
and `print.set` take their customary names, and names are matched case-, space- and
punctuation-insensitively (`L判`, `l`, `L-size` are one paper).
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
@@ -186,6 +191,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged |
| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present |
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
+48
View File
@@ -0,0 +1,48 @@
//! The page's automation entry point, `lightcraft.command(id, params)` (see `web.rs`): what the
//! params text means. Plain Rust, so it is tested natively.
use serde_json::Value;
/// The command parameters in `text`: a JSON object, or `{}` when the text is empty or blank (or
/// `null`). Anything else is an error, never `{}`: a command run without its params acts on the
/// selection or with defaults, so a typo such as `{ids: [3]}` must not delete or reset the
/// selected photos. The control channel and MCP refuse such params the same way.
pub fn params(text: &str) -> Result<Value, String> {
let text = text.trim();
if text.is_empty() {
return Ok(Value::Object(Default::default()));
}
match serde_json::from_str::<Value>(text) {
Ok(Value::Null) => Ok(Value::Object(Default::default())),
Ok(v @ Value::Object(_)) => Ok(v),
Ok(_) => Err("params must be a JSON object, e.g. {\"ids\": [3]}".into()),
Err(e) => Err(format!("params are not valid JSON ({e}); nothing was run")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn blank_or_null_params_are_an_empty_object() {
for text in ["", " ", "\n", "null", " null "] {
assert_eq!(params(text), Ok(json!({})), "{text:?}");
}
}
#[test]
fn an_object_is_passed_through() {
assert_eq!(params(r#"{"ids": [3], "rating": 5}"#), Ok(json!({"ids": [3], "rating": 5})));
}
/// Before, any of these ran the command with `{}`: `photo.delete` then removed the selection.
#[test]
fn invalid_json_or_a_non_object_is_refused() {
for text in ["{ids: [3]}", "{\"ids\": [3]", "ids=3", "[3]", "3", "\"{}\"", "true"] {
assert!(params(text).is_err(), "{text:?} must not run the command");
}
assert!(params("{ids: [3]}").unwrap_err().contains("nothing was run"));
}
}
+1
View File
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod automation;
pub mod backup;
pub mod bench;
pub mod files;
+43 -8
View File
@@ -23,7 +23,11 @@ use crate::store::{Originals, content_hash, download_name, storage_key};
use crate::wire::ThumbIndex;
use crate::workers::{THUMB_INDEX, Workers};
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
/// The web picker's accept list, derived from the engine's import extensions (both
/// cases) so no format the engine can import is silently filtered out of the dialog.
fn accept_list() -> String {
lightcraft_engine::import::accept_list()
}
/// After a failed save, wait this long before trying again (ms).
const SAVE_RETRY_MS: f64 = 2000.0;
@@ -123,7 +127,7 @@ fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Contex
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_multiple(true);
input.set_accept(ACCEPT);
input.set_accept(&accept_list());
let inp = input.clone();
let on_change = Closure::<dyn FnMut()>::new(move || {
if let Some(files) = inp.files() {
@@ -273,6 +277,8 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bo
}
}
flushing.set(false);
// the page title drops its unsaved • now, even when nothing else wakes the page
request_repaint();
}
/// Everything loaded before the UI starts.
@@ -402,6 +408,8 @@ struct WebApp {
lib_dir: String,
bench: Option<Bench>,
first_frame_logged: bool,
/// The page title last set (`document.title`), so it is written only when it changes.
title: String,
last_save: f64,
ui_written: Vec<u8>,
}
@@ -501,6 +509,7 @@ impl WebApp {
lib_dir,
bench: opts.bench.then(|| Bench::new(origin)),
first_frame_logged: false,
title: String::new(),
last_save: 0.0,
ui_written,
}
@@ -508,22 +517,37 @@ impl WebApp {
fn import_dropped(&mut self, ctx: &egui::Context) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
// a dialog is on screen (the card said so while the files hovered): nothing is read or imported
// (files read from an earlier drop still finish below)
if !dropped.is_empty() && self.app.ui.dialog.is_some() {
self.app.toast(ctx, lightcraft_ui_egui::i18n::tr("Close the open dialog, then drop the files again"));
return;
}
for f in dropped {
let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().to_string_lossy().to_string();
match f.bytes_async().await {
Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await,
Err(e) => log::warn!("reading dropped {name}: {e}"),
Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())),
}
});
}
let paths = self.originals.take_pending();
if !paths.is_empty() {
let n = paths.len();
match self.app.run("library.import", json!({"paths": paths})) {
Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })),
Err(e) => log::warn!("import: {e}"),
// what the import did, not what was submitted: duplicates and unsupported files add nothing,
// a drop that only finds duplicates says nothing (as the desktop import's toast does when it
// shows where they are), and one whose files could not be read says so
Ok(report) => {
if let Some(notice) = lightcraft_ui_egui::i18n::dropped_photos_outcome(&report) {
self.app.toast(ctx, notice);
}
}
Err(e) => {
log::warn!("import: {e}");
self.app.toast(ctx, lightcraft_ui_egui::i18n::dropped_import_failed(&e));
}
}
}
}
@@ -639,8 +663,11 @@ impl WebApp {
fn run_inbox(&mut self) {
let inbox = INBOX.with(|q| std::mem::take(&mut *q.borrow_mut()));
for (id, params, (resolve, reject)) in inbox {
let params: serde_json::Value = serde_json::from_str(&params).unwrap_or(json!({}));
let r = if id == "web.stats" { Ok(self.web_stats()) } else { self.app.run(&id, params) };
let r = match crate::automation::params(&params) {
Err(e) => Err(format!("{id}: {e}")),
Ok(_) if id == "web.stats" => Ok(self.web_stats()),
Ok(params) => self.app.run(&id, params),
};
let _ = match r {
Ok(v) => resolve.call1(&JsValue::NULL, &v.to_string().into()),
Err(e) => reject.call1(&JsValue::NULL, &e.into()),
@@ -686,6 +713,14 @@ impl eframe::App for WebApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
// eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title
let title = self.app.page_title(self.files.is_dirty() || self.flushing.get());
if title != self.title
&& let Some(doc) = window().and_then(|w| w.document())
{
doc.set_title(&title);
self.title = title;
}
if !self.first_frame_logged && !self.app.widgets.is_empty() {
self.first_frame_logged = true;
log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now());
+1 -1
View File
@@ -522,7 +522,7 @@ mod tests {
}
};
frame(&mut watch, &mut index); // an empty filtered view: no render request yet
cache.clear(); // File ▸ Clear Preview Cache
cache.clear(); // File ▸ Discard Preview Cache
frame(&mut watch, &mut index);
// the filter is removed: the first thumbnail request (`Workers::try_start`)
gone.extend(watch.observe(&cache, cache.generation(), &mut index));
+7
View File
@@ -247,6 +247,9 @@ mod tests {
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let (stream, _) = listener.accept().unwrap();
// Small test messages must not wait for Nagle's algorithm or a delayed ACK.
client.set_nodelay(true).unwrap();
stream.set_nodelay(true).unwrap();
before(&mut client);
let (tx, requests) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap();
@@ -316,6 +319,10 @@ mod tests {
// A dispatched command waits for its reply, not for socket input.
std::thread::sleep(Duration::from_millis(250));
req.reply.send(json!({"ok": true, "result": id})).unwrap();
if id == 2 {
// The worker must reach EOF even before the client reads its final reply.
connection.join();
}
let mut reply = String::new();
connection.client.read_line(&mut reply).unwrap();
let reply: Value = serde_json::from_str(&reply).unwrap();
+134 -23
View File
@@ -31,22 +31,14 @@ pub fn check(app: &str) -> Result<(), String> {
/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside.
pub fn open_with(path: &str, app: &str) -> Result<(), String> {
let mut c = match launcher(app)? {
Launcher::Default if cfg!(target_os = "macos") => {
let mut c = Command::new("open");
c.arg(path);
c
}
Launcher::Default if cfg!(target_os = "macos") => return run_open(&[path]),
Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path),
Launcher::Default => {
let mut c = Command::new("xdg-open");
c.arg(path);
c
}
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app, path]);
c
}
Launcher::MacApp(app) => return run_open(&["-a", &app, path]),
Launcher::Program(p) => {
let mut c = Command::new(p);
c.arg(path);
@@ -61,6 +53,22 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// macOS: run `open` and wait for it (it returns as soon as LaunchServices has handed the file
/// over), so an application it can't find or start is reported instead of silently doing nothing.
fn run_open(args: &[&str]) -> Result<(), String> {
let out = Command::new("open").args(args).output().map_err(|e| e.to_string())?;
open_result(out.status.success(), &String::from_utf8_lossy(&out.stderr))
}
/// What `open` said, as a result: its own message when it failed.
fn open_result(ok: bool, stderr: &str) -> Result<(), String> {
if ok {
return Ok(());
}
let msg = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("the application couldn't be started");
Err(msg.to_string())
}
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
/// still starting when it is started once per file. The system default opens them one by one
@@ -119,10 +127,15 @@ fn launcher(app: &str) -> Result<Launcher, String> {
fn find_photocraft() -> Option<Launcher> {
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string());
if cfg!(target_os = "macos") {
// LaunchServices finds the app wherever it is; these are only the usual places
// the usual places, then wherever Spotlight knows it (by bundle id or bundle name)
let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app"));
let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string())));
let usual = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
let spotlight = || {
let query = format!("kMDItemCFBundleIdentifier == '{PHOTOCRAFT_ID}' || kMDItemFSName == 'PhotoCraft.app'");
let out = Command::new("mdfind").arg(query).output().ok().filter(|o| o.status.success())?;
mdfind_app(&String::from_utf8_lossy(&out.stdout))
};
return usual.or_else(spotlight).map(|p| Launcher::MacApp(p.to_string_lossy().to_string()));
}
if cfg!(target_os = "windows") {
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
@@ -130,16 +143,34 @@ fn find_photocraft() -> Option<Launcher> {
return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program);
}
// Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which
// registers one on first run), else the Flatpak
let on_path =
env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
// registers one on first run; its Exec a path or a name on the PATH), an AppImage where people
// keep them, else the Flatpak
let path_dirs: Vec<PathBuf> = env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).collect();
let on_path = |name: &Path| resolve_program(name, &path_dirs);
let from_entry = || {
desktop_entry_dirs(env).into_iter().find_map(|d| {
let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?;
desktop_exec(&text).filter(|p| p.is_file())
desktop_exec(&text)
// a wrapper (`Exec=env VAR=1 /opt/PhotoCraft/photocraft %F`) would be started with the photo only:
// skip it and look further
.filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().to_lowercase().contains("photocraft")))
.and_then(|p| on_path(&p))
})
};
if let Some(p) = on_path.or_else(from_entry) {
let appimage = || {
let home = env("HOME")?;
let dirs = [Path::new(&home).join("Applications"), Path::new(&home).join(".local/bin")];
// both folders, so the newest version wins whichever it is in
let mut found: Vec<PathBuf> = dirs
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|rd| rd.flatten().map(|e| e.path()).filter(|p| is_photocraft_appimage(p) && p.is_file()).collect::<Vec<_>>())
.collect();
// the newest version by name (1.10 after 1.9)
found.sort_by_key(|p| version_key(&p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default()));
found.pop()
};
if let Some(p) = on_path(Path::new("photocraft")).or_else(from_entry).or_else(appimage) {
return Some(Launcher::Program(p));
}
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
@@ -205,10 +236,44 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
program.starts_with('/').then(|| PathBuf::from(program))
// a path, or a bare name (`photocraft %F`) the caller looks for on the PATH
(!program.is_empty()).then(|| PathBuf::from(program))
}
/// A program from a desktop entry or the PATH: a path as it is (if it is a file), a bare name
/// looked for in `dirs`. A leading `/` counts as absolute on every platform (desktop entries are
/// a POSIX format; `Path::is_absolute` wants a drive letter on Windows, where the tests run too).
fn resolve_program(name: &Path, dirs: &[PathBuf]) -> Option<PathBuf> {
if name.is_absolute() || name.starts_with("/") {
return Some(name.to_path_buf()).filter(|p| p.is_file());
}
dirs.iter().map(|d| d.join(name)).find(|p| p.is_file())
}
/// A sort key that orders the digit runs in a name by value (`1.9` before `1.10`).
fn version_key(name: &str) -> Vec<(u64, String)> {
let mut out = Vec::new();
let mut rest = name;
while !rest.is_empty() {
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let (num, tail) = rest.split_at(digits);
let text_len = tail.find(|c: char| c.is_ascii_digit()).unwrap_or(tail.len());
let (text, next) = tail.split_at(text_len);
out.push((num.parse().unwrap_or(u64::MAX), text.to_string()));
rest = next;
}
out
}
/// An AppImage of PhotoCraft by its file name (`PhotoCraft-1.2-x86_64.AppImage`, any case).
fn is_photocraft_appimage(p: &Path) -> bool {
let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
name.contains("photocraft") && name.ends_with(".appimage")
}
/// The first application bundle in `mdfind` output (one path per line).
fn mdfind_app(output: &str) -> Option<PathBuf> {
output.lines().map(str::trim).find(|l| l.ends_with(".app")).map(PathBuf::from)
}
#[cfg(test)]
@@ -275,13 +340,59 @@ mod tests {
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage")));
assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft")));
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup");
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), Some(PathBuf::from("photocraft")), "a bare name, for a PATH lookup");
assert_eq!(desktop_exec("[Desktop Entry]\n"), None);
let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string()));
assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications"));
assert!(dirs.contains(&PathBuf::from("/usr/share/applications")));
}
#[test]
fn a_bare_exec_name_is_found_on_the_path() {
let a = std::env::temp_dir().join(format!("lc-editors-a-{}", std::process::id()));
let b = std::env::temp_dir().join(format!("lc-editors-b-{}", std::process::id()));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(b.join("photocraft"), b"").unwrap();
let dirs = vec![a.clone(), b.clone()];
assert_eq!(resolve_program(Path::new("photocraft"), &dirs), Some(b.join("photocraft")));
assert_eq!(resolve_program(Path::new("nothere"), &dirs), None);
let abs = b.join("photocraft");
assert_eq!(resolve_program(&abs, &[]), Some(abs.clone()), "a path needs no PATH");
assert_eq!(resolve_program(&b.join("missing"), &dirs), None);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn appimage_versions_sort_numerically() {
let mut names = ["photocraft-1.10-x86_64.appimage", "photocraft-1.9-x86_64.appimage", "photocraft-1.2-x86_64.appimage"];
names.sort_by_key(|n| version_key(n));
assert_eq!(names[2], "photocraft-1.10-x86_64.appimage");
assert_eq!(names[0], "photocraft-1.2-x86_64.appimage");
}
#[test]
fn a_failed_open_is_an_error() {
assert!(open_result(true, "").is_ok());
assert!(open_result(false, "Unable to find application\n").is_err());
assert!(open_result(false, "").is_err(), "even when it says nothing");
}
#[test]
fn odd_detection_input_does_not_panic() {
for text in ["", "Exec=", "Exec=\"", "Exec=\"\"", "\u{0}\u{fffd}", "Exec= %F", "[Desktop Entry]\nExec=\u{e9}\u{301}"] {
let _ = desktop_exec(text);
let _ = mdfind_app(text);
let _ = reg_value(text);
}
for p in ["", "/", ".AppImage", "\u{e9}.appimage", "a/../b"] {
let _ = is_photocraft_appimage(Path::new(p));
}
let _ = desktop_entry_dirs(|_| None);
let _ = windows_candidates(None, Some("\"".into()), |_| None);
}
#[test]
fn other_editors_are_used_as_named() {
assert_eq!(launcher("").unwrap(), Launcher::Default);
+60 -19
View File
@@ -3,8 +3,10 @@
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
//!
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
//! `~/Pictures/LightCraft Library`; a new library starts with the
//! `$LIGHTCRAFT_LIBRARY`, else — with Settings ▸ General ▸ At launch set to *Ask me*, or Alt/Option
//! held as the window appears — the one chosen in a list of the recent libraries, else the library
//! last opened, else `~/Pictures/LightCraft Library`
//! ([`lightcraft_ui_egui::panels::library_problem::launch_plan`]); a new library starts with the
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
@@ -39,7 +41,7 @@ mod window_gpu;
use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem};
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
@@ -363,7 +365,7 @@ struct PrefsWriter {
impl PrefsWriter {
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
self.save_ui(&app.ui, app.library_problem.is_some())
self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing))
}
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
@@ -718,9 +720,13 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
}
/// The library session with the folders face models (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in.
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
/// (`<config>/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet:
/// the window lists the recent ones.
fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = match launch {
Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None),
Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo),
};
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
@@ -769,7 +775,9 @@ lightcraft — photo library + raw developer
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
OPTIONS:
--library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY)
--library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or
a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held,
else ~/Pictures/LightCraft Library)
--no-demo don't seed a new library with the procedural demo photos
--memory throwaway in-memory session (alias --demo); nothing is saved
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
@@ -900,15 +908,15 @@ fn main() -> eframe::Result {
if let Some(ui) = &prefs {
lightcraft_ui_egui::i18n::set_language(ui.language);
}
// --library, else the library last opened from Settings, else the default location
let library_dir = library_dir.or_else(|| {
prefs
.as_ref()
.map(|u| u.settings.library_path.clone())
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
.map(Into::into)
});
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
// --library, else LIGHTCRAFT_LIBRARY, else the list of recent libraries if Settings say Ask
// me, else the library last opened from Settings, else the default location
let launch = lightcraft_ui_egui::panels::library_problem::launch_plan(
in_memory,
library_dir,
std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into),
prefs.as_ref().map(|u| &u.settings),
lightcraft_engine::library::default_dir(),
);
// GPU compute: off if the preference says so, or if the last launch died creating the device
// (issue #136) — before anything can create it
let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory));
@@ -961,7 +969,7 @@ fn main() -> eframe::Result {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
came_up_app.store(true, std::sync::atomic::Ordering::Relaxed);
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
let (mut session, mut problem) = open_session(in_memory, &launch, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
@@ -972,6 +980,11 @@ fn main() -> eframe::Result {
if let Some(ui) = prefs {
app.ui = ui;
}
// the recent libraries to choose from (issue #1290), checked on a worker once shown
if launch == Launch::Ask {
problem = Some(LibraryProblem::choose_later(&app.ui.settings));
}
app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. });
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
@@ -1145,12 +1158,40 @@ mod tests {
/// adds to Apple Photos on a worker (headless sessions wait instead).
#[test]
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
let (s, problem) = open_session(true, None, false);
let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false);
assert!(problem.is_none());
assert!(s.apple_photos_background);
assert!(!lightcraft_engine::Session::new().apple_photos_background);
}
/// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo
/// photos) before the user chooses, and the settings are still saved while the list shows (a
/// removed entry is kept), unlike the failed-library window's temporary session.
#[test]
fn asking_at_launch_opens_nothing_and_still_saves_the_settings() {
let (s, problem) = open_session(false, &Launch::Ask, true);
assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty());
assert!(s.apple_photos_background, "the same desktop session as an opened one");
let d = dir("writer-ask");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(s, Services::default());
app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()];
app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings));
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
app.ui.settings.recent_libraries.remove(0);
w.save(&app).unwrap();
let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved.settings.recent_libraries, ["/gone/b"]);
// the failed-library window still writes nothing
app.library_problem = Some(LibraryProblem::new("/gone/b", "not there"));
app.ui.settings.recent_libraries.clear();
w.save(&app).unwrap();
let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved.settings.recent_libraries, ["/gone/b"]);
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
+30 -67
View File
@@ -16,7 +16,9 @@ use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, channel};
use lightcraft_ui_egui::LightcraftApp;
use lightcraft_ui_egui::menubar::{MenuNode, menu_bar};
use lightcraft_ui_egui::menubar::{
MenuNode, menu_bar, native_item_presentation, native_menu_delivers as menu_delivers, native_menu_structure as structure_of,
};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
@@ -57,8 +59,10 @@ struct Item {
}
impl Item {
/// The row's text as installed: `label` is already what the row shows (translated, with a
/// borrowed key's hint, `native_item_presentation`).
fn native_label(&self) -> String {
lightcraft_ui_egui::menubar::display_item_label(&self.cmd, &self.params, &self.label).replace('&', "&&")
self.label.replace('&', "&&")
}
/// Puts `want` on the native item. muda's `set_accelerator(None)` does nothing on macOS (the
@@ -276,13 +280,6 @@ fn yields_to_text(sc: &str) -> bool {
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
}
/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function
/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run
/// those even though their menu item shows the key.
fn menu_delivers(sc: &str) -> bool {
sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::<u8>().is_ok()))
}
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
@@ -293,34 +290,6 @@ fn label_text(s: &str) -> String {
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
}
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
fn walk(n: &[MenuNode], out: &mut String) {
for x in n {
match x {
MenuNode::Item { id, params, checked, shortcut, .. } => {
out.push_str(&MenuNode::key(id, params));
out.push(if checked.is_some() { 'c' } else { 'i' });
out.push_str(shortcut.as_deref().unwrap_or(""));
}
MenuNode::Separator => out.push('-'),
MenuNode::Submenu { label, children } => {
out.push('[');
out.push_str(label);
walk(children, out);
out.push(']');
}
}
out.push(';');
}
}
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
}
s
}
/// Drop a trailing separator (left behind when an item moved to the app menu).
fn tidy_separators(mut nodes: Vec<MenuNode>) -> Vec<MenuNode> {
while matches!(nodes.last(), Some(MenuNode::Separator)) {
@@ -353,9 +322,10 @@ impl NativeMenu {
m
}
/// Recreate native handles and shortcut ownership from the current shared menu model.
fn rebuild(&mut self, app: &mut LightcraftApp) {
let bar = menu_bar(app);
self.structure = structure_of(&bar);
self.structure = structure_of(&bar, &app.ui.settings.keymap);
self.menu = Menu::new();
self.items.clear();
self.app_items.clear();
@@ -389,7 +359,7 @@ impl NativeMenu {
shortcut: Some(shortcut.to_string()),
accel,
current: accel,
label: text.to_string(),
label: lightcraft_ui_egui::menubar::display_item_label(cmd, &Value::Null, text).to_string(),
enabled: true,
checked: None,
});
@@ -414,7 +384,7 @@ impl NativeMenu {
let nodes = tidy_separators(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, title != "Help");
self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help");
if title == "File" {
// ⌘W, the system's own item
let _ = sub.append_items(&[
@@ -441,8 +411,9 @@ impl NativeMenu {
self.publish_shortcuts(app);
}
/// 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], swappable: bool) {
fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool) {
for n in nodes {
match n {
MenuNode::Separator => {
@@ -450,7 +421,7 @@ impl NativeMenu {
}
MenuNode::Submenu { label, children } => {
let s = Submenu::new(label_text(label), true);
self.append_nodes(&s, children, swappable);
self.append_nodes(&s, children, keymap, swappable);
let _ = sub.append(&s);
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
@@ -458,26 +429,16 @@ impl NativeMenu {
if self.items.contains_key(&key) {
continue; // muda ids must be unique
}
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
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 handle = match checked {
Some(c) => {
let it = CheckMenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
*c,
accel,
);
let it = CheckMenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, *c, accel);
let _ = sub.append(&it);
Handle::Check(it)
}
None => {
let it = MenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
accel,
);
let it = MenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, accel);
let _ = sub.append(&it);
Handle::Plain(it)
}
@@ -493,7 +454,7 @@ impl NativeMenu {
shortcut: shortcut.clone(),
accel,
current: accel,
label: label.clone(),
label: native_label,
enabled: *enabled,
checked: *checked,
},
@@ -533,23 +494,25 @@ impl NativeMenu {
}
}
let bar = menu_bar(app);
if structure_of(&bar) != self.structure {
if structure_of(&bar, &app.ui.settings.keymap) != self.structure {
// (no early return: a rebuild installs every accelerator and resets the state they were synced
// to, so the sync below runs in this same frame and no key reaches the menu in between)
self.rebuild(app);
}
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
/// 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),
MenuNode::Item { id, params, label, enabled, checked, .. } => {
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 };
if it.label != *label {
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(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
Handle::Plain(h) => h.set_text(native_label.replace('&', "&&")),
Handle::Check(h) => h.set_text(native_label.replace('&', "&&")),
}
it.label = label.clone();
it.label = native_label;
}
if it.enabled != *enabled {
match &it.handle {
@@ -570,7 +533,7 @@ impl NativeMenu {
}
}
}
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items, &app.ui.settings.keymap);
let focus = ctx.egui_wants_keyboard_input();
let capturing = app.recording_shortcut.is_some();
@@ -610,7 +573,7 @@ mod tests {
let mut seen = std::collections::BTreeSet::new();
for language in Locale::ALL {
set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
assert!(seen.insert(structure_of(&bar, &app.ui.settings.keymap)), "{language:?} shares another language's menu structure key");
}
set_language(Locale::En);
}
+34 -13
View File
@@ -93,7 +93,6 @@ pub fn choose(files: &Files) -> Chosen {
let compiled = wgpu::Instance::enabled_backend_features();
let os = std::env::consts::OS;
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
let mut problems = Vec::new();
let chosen = if cfg!(windows) {
pick_probed(&order, &mut problems)
@@ -104,18 +103,7 @@ pub fn choose(files: &Files) -> Chosen {
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
};
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
if !problems.is_empty() {
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
}
let notice = notice.map(|n| {
format!(
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
(see docs/gpu-pipeline.md → Troubleshooting).",
which.name(),
app = "LightCraft",
var = "LIGHTCRAFT_GPU_BACKEND",
)
});
let notice = user_notice(failed, forced, &problems, which);
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
let mut wgpu_options = base_options();
let renderer = match (which, existing) {
@@ -134,6 +122,25 @@ pub fn choose(files: &Files) -> Chosen {
Chosen { renderer, wgpu_options, which, notice }
}
/// What to tell the user about the window's renderer. A renderer that failed its probe is skipped
/// silently (it is logged, and the one that worked is remembered): the app works, so a pop-up with
/// wgpu's error only alarms (#1624). Shown: the last start died, or the forced renderer failed.
fn user_notice(failed: Option<WindowRenderer>, forced: Option<WindowRenderer>, problems: &[String], which: WindowRenderer) -> Option<String> {
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
if forced.is_some() && !problems.is_empty() {
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
}
notice.map(|n| {
format!(
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
(see docs/gpu-pipeline.md → Troubleshooting).",
which.name(),
app = "LightCraft",
var = "LIGHTCRAFT_GPU_BACKEND",
)
})
}
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
pub fn base_options() -> WgpuConfiguration {
let mut c = WgpuConfiguration::default();
@@ -269,6 +276,20 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// #1624: a renderer skipped by the probe (DX12 → Vulkan) is no pop-up; a died start or a
/// failed forced renderer still is.
#[test]
fn probe_fallback_is_silent() {
let (dx, vk) = (WindowRenderer::Wgpu(Backends::DX12), WindowRenderer::Wgpu(Backends::VULKAN));
let problems = vec!["The dx12 renderer failed (Validation Error).".to_string()];
assert_eq!(user_notice(None, None, &problems, vk), None);
assert_eq!(user_notice(None, None, &[], vk), None);
let forced = user_notice(None, Some(dx), &problems, vk).unwrap();
assert!(forced.starts_with("The dx12 renderer failed") && forced.contains("now draws its window with vulkan"), "{forced}");
let died = user_notice(Some(dx), None, &problems, vk).unwrap();
assert!(died.contains("last time") && !died.contains("Validation Error"), "{died}");
}
/// The crash report: a pipeline that fails validation must come back as an error, never as
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
#[test]
+3
View File
@@ -61,6 +61,9 @@ sync_github_name = false
real_name = "Ege Gursel"
sync_github_name = true
[people.valenfys]
sync_github_name = true
[people.mahmatias]
display_name = "Márcio Matias de Andrade"
sync_github_name = true
+107 -4
View File
@@ -43,19 +43,63 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
/// anything, it never overflows.
/// anything, it never overflows. Stricter than [`iso_seconds`], which reads what it can for sorting:
/// a time cut short, seconds that aren't digits or anything after the zone is `None` here, not
/// written back as a clean-looking time.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
let iso = iso.trim();
let tail = iso.get(tail_start(iso)?..)?;
let t = iso_seconds(iso)?.checked_add(secs)?;
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
if !(first..=last).contains(&t) {
return None;
}
let iso = iso.trim();
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
/// Where the part of an ISO time that [`shift_iso`] keeps as it was (fraction, zone) starts, or
/// `None` unless `iso` is a date alone (`2026-01-01`) or a date and `HH:MM`, optional `:SS` with
/// an optional fraction (`.5`), and an optional zone (`Z`, `+02:00`, `-0500`, `+02`), and nothing after.
fn tail_start(iso: &str) -> Option<usize> {
let b = iso.as_bytes();
let digits = |x: &[u8]| !x.is_empty() && x.iter().all(u8::is_ascii_digit);
let digits_at = |a: usize, z: usize| b.get(a..z).is_some_and(digits);
let at = |i: usize, c: u8| b.get(i) == Some(&c);
if !(digits_at(0, 4) && at(4, b'-') && digits_at(5, 7) && at(7, b'-') && digits_at(8, 10)) {
return None;
}
if b.len() == 10 {
return Some(10);
}
if !((at(10, b'T') || at(10, b' ')) && digits_at(11, 13) && at(13, b':') && digits_at(14, 16)) {
return None;
}
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
let start = if !at(16, b':') {
16
} else if digits_at(17, 19) {
19
} else {
return None;
};
let mut zone = b.get(start..)?;
// a fraction only after the seconds: `10:00.5` is half a minute, not half a second
if let ([b'.', rest @ ..], 19) = (zone, start) {
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
zone = rest.get(n..)?;
if n == 0 {
return None;
}
}
let ok = match zone {
[] | [b'Z'] => true,
[b'+' | b'-', h1, h2, b':', m1, m2] => digits(&[*h1, *h2, *m1, *m2]),
[b'+' | b'-', hm @ ..] => matches!(hm.len(), 2 | 4) && digits(hm),
_ => false,
};
ok.then_some(start)
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
/// seconds or time). `None` if it isn't a valid date.
pub fn normalize_iso(s: &str) -> Option<String> {
@@ -260,6 +304,65 @@ mod tests {
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
}
#[test]
fn shift_refuses_a_time_it_cant_fully_read() {
for bad in [
"2026-01-01T10:0",
"2026-01-01T10",
"2026-01-01T",
"2026-01-01T10:00:xx",
"2026-01-01T10:00:0",
"2026-01-01T10:00:00+",
"2026-01-01T10:00:00+2",
"2026-01-01T10:00:00+02:0",
"2026-01-01T10:00:00.",
"2026-01-01T10:00:00.5.5",
"2026-01-01T10:00.5",
"2026-01-01T10:00:00ZZ",
"2026-01-01T10:00:00 and more",
"2026-01-01T10:00 and more",
"2026-01-01x10:00:00",
"2026/01/01T10:00:00",
] {
assert_eq!(shift_iso(bad, 3600), None, "{bad} doesn't fully read, so it must not be written back");
}
// multi-byte characters where the time, the seconds and the zone start never panic
for at in [11, 16, 18, 21] {
let mut s = String::from("2026-01-01T10:00:00+02:00");
s.replace_range(at..at + 1, "é");
assert_eq!(shift_iso(&s, 3600), None, "{s}");
assert_eq!(shift_iso(s.get(..at + 2).unwrap(), 3600), None, "{s}");
}
}
#[test]
fn shift_keeps_a_fraction_and_zone_and_reads_back() {
for (from, to) in [
("2026-01-01", "2026-01-01T01:00:00"),
(" 2026-01-01T10:00 ", "2026-01-01T11:00:00"),
("2026-01-01 10:00:30", "2026-01-01T11:00:30"),
("2026-01-01T10:00:30.5", "2026-01-01T11:00:30.5"),
("2026-01-01T10:00:30Z", "2026-01-01T11:00:30Z"),
("2026-01-01T10:00:30+02:00", "2026-01-01T11:00:30+02:00"),
("2026-01-01T10:00:30-0500", "2026-01-01T11:00:30-0500"),
("2026-01-01T10:00:30+02", "2026-01-01T11:00:30+02"),
("2026-01-01T10:00:30.125+02:00", "2026-01-01T11:00:30.125+02:00"),
] {
assert_eq!(shift_iso(from, 3600).as_deref(), Some(to), "{from}");
assert_eq!(shift_iso(to, 0).as_deref(), Some(to), "{to} reads back");
}
}
/// Sorting, grouping and date rules read with `iso_seconds`, which stays lenient: only the
/// write path refuses what it can't fully read.
#[test]
fn reading_stays_lenient_for_sorting() {
let at_ten = iso_seconds("2026-01-01T10:00:00");
assert_eq!(iso_seconds("2026-01-01T10:00:00 and more"), at_ten);
assert_eq!(iso_seconds("2026-01-01T10:00:xx"), at_ten);
assert_eq!(iso_seconds("2026-01-01T10:0"), iso_seconds("2026-01-01"));
}
#[test]
fn impossible_times_are_rejected_not_rolled_over() {
for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
+2
View File
@@ -1193,4 +1193,6 @@ mod tests_repair;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_tally;
#[cfg(test)]
mod tests_torn_append;
+68 -1
View File
@@ -432,12 +432,79 @@ thread_local! {
std::cell::RefCell::new(std::collections::HashMap::new());
}
thread_local! {
/// While [`AlbumPass`] is alive on this thread: the photos of each smart album that was counted whole, with
/// the longest chain of smart albums (itself included) its rules lead through.
static PASS: std::cell::RefCell<Option<std::collections::HashMap<crate::AlbumId, (usize, crate::tally::Members)>>> =
const { std::cell::RefCell::new(None) };
}
/// One pass over one catalog state that counts every smart album whole. Each album put in with `remember`
/// answers [`smart_album_holds`] from its photos, not from its rules, until the pass is dropped (it can't outlive
/// the state it was made of: it exists only inside the counting function).
///
/// Only an album whose rules lead to no loop may be remembered, and only with the height of its chain: its answer
/// then doesn't depend on which albums are being evaluated around it, as long as the chain still fits under
/// [`MAX_ALBUM_DEPTH`] from where it is asked (checked on every use; otherwise the rules are evaluated as before).
pub(crate) struct AlbumPass {
outer: Option<std::collections::HashMap<crate::AlbumId, (usize, crate::tally::Members)>>,
}
impl AlbumPass {
pub(crate) fn begin() -> AlbumPass {
AlbumPass { outer: PASS.with_borrow_mut(|p| p.replace(std::collections::HashMap::new())) }
}
/// `id` holds exactly `members`, and its rules lead through a chain of `height` smart albums (itself included),
/// none of them in a loop.
pub(crate) fn remember(&self, id: crate::AlbumId, height: usize, members: crate::tally::Members) {
PASS.with_borrow_mut(|p| {
if let Some(p) = p {
p.insert(id, (height, members));
}
});
}
/// Ends the pass, giving back what was remembered.
pub(crate) fn finish(self) -> std::collections::HashMap<crate::AlbumId, crate::tally::Members> {
PASS.with_borrow_mut(Option::take).unwrap_or_default().into_iter().map(|(id, (_, members))| (id, members)).collect()
}
}
impl Drop for AlbumPass {
fn drop(&mut self) {
let outer = self.outer.take();
PASS.with_borrow_mut(|p| *p = outer);
}
}
/// What a counting pass knows of smart album `id` for `photo`, when asking now can't give another answer than
/// evaluating its rules would: the chain below it fits under [`MAX_ALBUM_DEPTH`] from the albums being evaluated.
fn pass_answer(id: crate::AlbumId, photo: crate::PhotoId) -> Option<bool> {
PASS.with_borrow(|p| {
let (height, members) = p.as_ref()?.get(&id)?;
let depth = EVALUATING.with_borrow(Vec::len);
(depth + height <= MAX_ALBUM_DEPTH).then(|| members.holds(photo))
})
}
#[cfg(test)]
thread_local! {
/// How many times a smart album's rules were worked out on this thread (tests count them).
pub(crate) static EVALUATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the
/// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing).
pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool {
if let Some(known) = pass_answer(id, photo) {
return known;
}
if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) {
return known;
}
#[cfg(test)]
EVALUATED.with(|n| n.set(n.get() + 1));
// the outermost question forgets the answers when it ends, however it ends
struct Forget(bool);
impl Drop for Forget {
@@ -456,7 +523,7 @@ pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, match
}
/// How deep smart albums may test smart albums before the innermost counts as holding nothing.
const MAX_ALBUM_DEPTH: usize = 32;
pub(crate) const MAX_ALBUM_DEPTH: usize = 32;
/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id`
/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is
+91 -14
View File
@@ -8,7 +8,9 @@
use std::collections::{HashMap, HashSet};
use crate::{AlbumId, Aspects, Catalog, PhotoId};
use crate::query::Filter;
use crate::rules::{AlbumPass, MAX_ALBUM_DEPTH};
use crate::{Album, AlbumId, Aspects, Catalog, PhotoId};
/// The most photos an edit may touch for the counts to follow it photo by photo.
const MAX_FOLLOWED: usize = 256;
@@ -20,7 +22,7 @@ const WORDS_TO_START: usize = 1024;
/// The photos a smart album holds.
#[derive(Clone, Debug)]
enum Members {
pub(crate) enum Members {
/// One bit a photo id, and how many are set.
Bits {
words: Vec<u64>,
@@ -41,8 +43,7 @@ impl Members {
}
}
#[cfg(test)]
fn holds(&self, id: PhotoId) -> bool {
pub(crate) fn holds(&self, id: PhotoId) -> bool {
match self {
Members::Bits { words, .. } => {
usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0)
@@ -114,21 +115,46 @@ pub struct AlbumTally {
impl AlbumTally {
/// Counted from every photo.
///
/// A smart album that tests another (`album is #14`) reads that album's photos, counted once for the whole
/// pass, instead of working its rules out again for every photo: albums are counted shortest chain first.
/// The photos are kept only until this function returns, so they can't be older than the catalog counted.
/// An album whose rules lead into a loop, or through a chain too deep to be sure of, is counted by its rules
/// as before: the pass ends before the first of them (they sort last), so nothing they evaluate reads it.
pub fn of(cat: &Catalog) -> AlbumTally {
let heights = chain_heights(cat);
let height_of = |id: AlbumId| heights.get(&id).copied().flatten().filter(|h| *h <= MAX_ALBUM_DEPTH);
let mut smart: Vec<AlbumId> = cat.albums().filter(|a| a.is_smart()).map(|a| a.id).collect();
smart.sort_by_key(|id| height_of(*id).unwrap_or(usize::MAX));
let held_by = |id: AlbumId| {
let mut held = Members::none();
for p in cat.photos().filter(|p| cat.album_contains(id, p)) {
held.set(p.id, true);
}
held
};
let mut counts = HashMap::new();
let mut members = HashMap::new();
for album in cat.albums() {
if album.is_smart() {
let mut held = Members::none();
for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) {
held.set(p.id, true);
}
counts.insert(album.id, held.len());
members.insert(album.id, held);
} else {
counts.insert(album.id, cat.album_count(album.id));
let split = smart.partition_point(|id| height_of(*id).is_some());
let (with_height, without) = smart.split_at(split);
let pass = AlbumPass::begin();
for id in with_height {
let held = held_by(*id);
counts.insert(*id, held.len());
if let Some(height) = height_of(*id) {
pass.remember(*id, height, held);
}
}
members.extend(pass.finish());
for id in without {
let held = held_by(*id);
counts.insert(*id, held.len());
members.insert(*id, held);
}
for album in cat.albums().filter(|a| !a.is_smart()) {
counts.insert(album.id, cat.album_count(album.id));
}
AlbumTally { revision: cat.revision, counts, members }
}
@@ -184,6 +210,57 @@ impl AlbumTally {
}
}
/// For each smart album, the most smart albums in a chain its rules lead through (itself included), or `None`
/// when a loop is reachable from it (its answer may then depend on which albums are being evaluated around it).
fn chain_heights(cat: &Catalog) -> HashMap<AlbumId, Option<usize>> {
struct Frame {
id: AlbumId,
tests: Vec<AlbumId>,
next: usize,
tallest: usize,
loops: bool,
}
let tests = |id: AlbumId| -> Vec<AlbumId> {
let tested = cat.album(id).and_then(|a| a.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
tested.into_iter().filter(|t| cat.album(*t).is_some_and(Album::is_smart)).collect()
};
let frame = |id: AlbumId| Frame { id, tests: tests(id), next: 0, tallest: 0, loops: false };
let mut done: HashMap<AlbumId, Option<usize>> = HashMap::new();
let mut open: HashSet<AlbumId> = HashSet::new();
for root in cat.albums().filter(|a| a.is_smart()).map(|a| a.id) {
if done.contains_key(&root) {
continue;
}
open.insert(root);
let mut stack = vec![frame(root)];
while let Some(top) = stack.last_mut() {
if let Some(&kid) = top.tests.get(top.next) {
top.next += 1;
match done.get(&kid) {
Some(Some(h)) => top.tallest = top.tallest.max(*h),
Some(None) => top.loops = true,
None if open.contains(&kid) => top.loops = true,
None => {
open.insert(kid);
stack.push(frame(kid));
}
}
} else if let Some(finished) = stack.pop() {
open.remove(&finished.id);
let height = (!finished.loops).then_some(finished.tallest + 1);
done.insert(finished.id, height);
if let Some(parent) = stack.last_mut() {
match height {
Some(h) => parent.tallest = parent.tallest.max(h),
None => parent.loops = true,
}
}
}
}
}
done
}
#[cfg(test)]
mod tests {
use super::*;
+172
View File
@@ -0,0 +1,172 @@
//! A whole count of every album (`AlbumTally::of`) keeps each smart album's photos for the pass, so an album testing
//! another doesn't work its rules out again. The counts must be the ones each album gets counted alone.
use serde_json::json;
use super::*;
use crate::tally::AlbumTally;
fn smart(id: u64, rules: serde_json::Value) -> Album {
let rule_set = Some(serde_json::from_value(rules).unwrap());
Album { smart: Some(Box::new(Filter { rule_set, ..Default::default() })), ..Album::new(AlbumId(id), format!("Smart {id}")) }
}
fn photos(n: u64) -> Vec<Op> {
(0..n)
.map(|i| {
// folders with capitals, accents and a Windows path, so the text rules see every spelling
let dir = ["Asian/Japanese", "Été/Ünï", "C:\\Users\\Me\\Pictures", "plain", "[SITES]/x"][(i % 5) as usize];
let mut p = Photo::new(
PhotoId(i + 1),
Source::File { path: format!("/Volumes/share/photos/{dir}/name{}/IMG_{i:04}.jpg", i % 9) },
&format!("IMG_{i:04}.jpg"),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.rating = (i % 6) as u8;
p.flag = [Flag::None, Flag::Pick, Flag::Reject][(i % 3) as usize];
p.label = (i % 4 == 0).then_some(ColorLabel::Red);
p.meta.keywords = (0..i % 4).map(|k| format!("kw{k}")).collect();
p.deleted = i % 37 == 0;
Op::AddPhoto { photo: Box::new(p) }
})
.collect()
}
fn is(album: u64) -> serde_json::Value {
json!({"field": "album", "op": "is", "value": album})
}
fn is_not(album: u64) -> serde_json::Value {
json!({"field": "album", "op": "isNot", "value": album})
}
fn path(op: &str, value: &str) -> serde_json::Value {
json!({"field": "filePath", "op": op, "value": value})
}
/// Every album's count in `c`, each album counted alone (no pass, rules worked out per photo).
fn counted_alone(c: &Catalog) -> Vec<(AlbumId, usize)> {
let mut v: Vec<_> = c.albums().map(|a| (a.id, c.album_count(a.id))).collect();
v.sort_unstable();
v
}
fn counted_together(c: &Catalog) -> Vec<(AlbumId, usize)> {
let tally = AlbumTally::of(c);
let mut v: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect();
v.sort_unstable();
v
}
#[test]
fn a_whole_count_equals_each_album_counted_alone() {
let mut c = Catalog::new();
let mut ops = photos(240);
let mut plain = Album::new(AlbumId(1), "Picks");
plain.photos = (0..240).step_by(4).map(|i| PhotoId(i + 1)).collect();
let folder = Album { folder: true, ..Album::new(AlbumId(2), "Trips") };
let inside = Album { parent: Some(AlbumId(2)), photos: (0..240).step_by(3).map(|i| PhotoId(i + 1)).collect(), ..Album::new(AlbumId(3), "In") };
ops.extend([plain, folder, inside].map(|album| Op::AddAlbum { album }));
// the inner albums: #2-like, #14-like, then albums chained on it, the other way round, in groups and in different orders
ops.push(Op::AddAlbum {
album: smart(
10,
json!({"match": "any", "rules": [{"field": "flag", "op": "is", "value": "reject"}, {"field": "label", "op": "is", "value": "red"}]}),
),
});
ops.push(Op::AddAlbum {
album: smart(11, json!({"match": "all", "rules": [is_not(10), path("notContains", "Été"), path("notContains", "plain")]})),
});
for k in 0..9u64 {
let tail = path("contains", &format!("NAME{k}"));
let rules = match k % 3 {
0 => json!({"match": "all", "rules": [is(11), tail]}),
1 => json!({"match": "all", "rules": [json!({"field": "keywordCount", "op": "lte", "value": 2}), is(11), tail]}),
_ => json!({"match": "all", "rules": [tail, is(11)]}),
};
ops.push(Op::AddAlbum { album: smart(12 + k, rules) });
}
ops.push(Op::AddAlbum {
album: smart(
21,
json!({"match": "all", "rules": [{"group": {"match": "any", "rules": [is(11), is(1)]}}, {"field": "rating", "op": "gte", "value": 2}]}),
),
});
ops.push(Op::AddAlbum { album: smart(22, json!({"match": "all", "rules": [is_not(21), is(3)]})) });
ops.push(Op::AddAlbum { album: smart(23, json!({"match": "any", "rules": [is(2), is(999), is(1)]})) });
// a loop saved anyway (30 ↔ 31), albums that test it from outside (32, 33), and one that tests both worlds
ops.push(Op::AddAlbum { album: smart(30, json!({"rules": []})) });
ops.push(Op::AddAlbum { album: smart(31, json!({"rules": [is_not(30)]})) });
ops.push(Op::AddAlbum { album: smart(32, json!({"rules": [is(31), {"field": "rating", "op": "gte", "value": 1}]})) });
ops.push(Op::AddAlbum { album: smart(33, json!({"rules": [is_not(32), is(11)]})) });
ops.push(Op::AddAlbum { album: smart(34, json!({"rules": [is(34)]})) });
// a chain deeper than the limit on how deep albums may test albums
ops.push(Op::AddAlbum { album: smart(100, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) });
for k in 101..=140u64 {
ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k - 1)]})) });
}
ops.push(Op::AddAlbum { album: smart(141, json!({"rules": [is(140), is_not(11)]})) });
// an album past the depth limit that also tests a memoized album directly: where the shared album is read from the
// pass near the top, the chain below must still find it answered as it always did (200 ← 231 … 201, height 32)
ops.push(Op::AddAlbum { album: smart(200, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) });
for k in 201..=230u64 {
ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k + 1)]})) });
}
ops.push(Op::AddAlbum { album: smart(231, json!({"rules": [is(200)]})) });
ops.push(Op::AddAlbum { album: smart(250, json!({"match": "all", "rules": [is(200), is(201)]})) });
c.apply(Op::Batch { ops }).unwrap();
c.apply(Op::SetAlbumRules {
id: AlbumId(30),
rules: Box::new(Filter { rule_set: Some(serde_json::from_value(json!({"rules": [is_not(31)]})).unwrap()), ..Default::default() }),
})
.unwrap();
let alone = counted_alone(&c);
let count = |id: u64| alone.iter().find(|(a, _)| a.0 == id).map(|(_, n)| *n);
assert!(count(11) > Some(0) && count(131) > Some(0), "{alone:?}");
assert_eq!(count(132), Some(0), "the chain is too deep from here: holds nothing, as it did");
assert!(count(250) > Some(0), "200 is answered near the top, so the chain finds it answered");
assert_eq!(counted_together(&c), alone);
// the pass leaves nothing behind: the same answers whichever album asks first, and again after an edit
c.apply(Op::SetAlbumRules {
id: AlbumId(10),
rules: Box::new(Filter {
rule_set: Some(serde_json::from_value(json!({"rules": [{"field": "rating", "op": "is", "value": 5}]})).unwrap()),
..Default::default()
}),
})
.unwrap();
let after = counted_alone(&c);
assert_ne!(after, alone);
assert_eq!(counted_together(&c), after);
}
/// The reason for the pass: with an album tested by many, its rules are worked out once per photo, not once per
/// photo for each album that tests it.
#[test]
fn an_album_tested_by_many_is_worked_out_once_a_photo() {
let mut c = Catalog::new();
let mut ops: Vec<Op> = (0..60u64)
.map(|i| {
let mut p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-01-01T00:00:00");
p.rating = (i % 6) as u8;
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
ops.push(Op::AddAlbum { album: smart(1, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) });
for k in 0..10u64 {
ops.push(Op::AddAlbum { album: smart(2 + k, json!({"rules": [is(1), {"field": "rating", "op": "lte", "value": k % 6}]})) });
}
c.apply(Op::Batch { ops }).unwrap();
let alone = counted_alone(&c);
crate::rules::EVALUATED.with(|n| n.set(0));
let tally = AlbumTally::of(&c);
let worked = crate::rules::EVALUATED.with(std::cell::Cell::get);
assert_eq!(worked, 11 * 60, "each of the 11 smart albums asks each of the 60 photos once");
let mut together: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect();
together.sort_unstable();
assert_eq!(together, alone);
}
+347 -28
View File
@@ -7,6 +7,16 @@
//! there"; the render worker finds out if it isn't), known answers are rechecked after
//! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a
//! fresh check, as before.
//!
//! The same worker can check a list of files, such as every library photo's, now and then
//! ([`Availability::sweep`]), one at a time and only while nothing is asked. That is for the
//! Missing Photos count ([`Availability::is_gone`]): one stream of checks on a share, not one per
//! thing that wants to know. The app starts the sweeps; nothing here does.
//!
//! A sweep of a large library on a share takes tens of minutes, and its `stat`s compete with the
//! reads of an import or a scan on the same share. So the worker starts no sweep check while the
//! caller says files are busy ([`Availability::sweep_yields_to`]); what is asked still gets
//! answered (within [`YIELD_POLL`]), and the sweep goes on where it stopped when the work ends.
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
@@ -14,9 +24,16 @@ use std::time::Duration;
/// Checks whether a path exists (injectable: tests use a slow one).
pub type ExistsProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// Called (from the worker) when an answer changed, e.g. to repaint the UI.
/// Called (from the worker) when an answer changed, e.g. to repaint the UI (a sweep's first look
/// at a file that is there changes no answer anyone sees, so it wakes nobody).
pub type Waker = Arc<dyn Fn() + Send + Sync>;
/// Whether file work that a sweep should not compete with is going on.
pub type Busy = Arc<dyn Fn() -> bool + Send + Sync>;
/// How often a sweep that is giving way looks whether the work has ended.
const YIELD_POLL: Duration = Duration::from_millis(250);
/// How long a cached answer is used before it is checked again (it is still returned meanwhile).
pub const RECHECK: Duration = Duration::from_secs(15);
@@ -31,9 +48,17 @@ struct State {
known: HashMap<String, (bool, std::time::Instant)>,
queue: VecDeque<String>,
queued: HashSet<String>,
/// What a sweep has left to check: after the queue, whatever it holds.
sweep: VecDeque<String>,
/// The files found not there (and not found there since).
gone: HashSet<String>,
worker: bool,
/// Bumped whenever an answer changes (callers recompute what depends on it).
generation: u64,
/// Bumped when the sweep under way is dropped for another one ([`Availability::restart_sweeps`]).
sweep_epoch: u64,
/// Whether file work a sweep gives way to is going on.
busy: Option<Busy>,
/// Bumped whenever a file is found gone, or found back.
gone_generation: u64,
}
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
@@ -66,7 +91,8 @@ impl Availability {
self.probe = probe;
let mut st = lock(&self.state);
st.known.clear();
st.generation += 1;
st.gone.clear();
st.gone_generation += 1;
}
/// Answer from the cache and check on a worker thread from now on; `wake` runs when an
@@ -78,6 +104,14 @@ impl Availability {
self.background = Some(wake);
}
/// The sweep starts no check while `busy` says so (the caller's import, scan or Local read in
/// flight); asked files are answered all the same, and the sweep resumes where it was left when
/// `busy` turns false. `busy` should read state that a task's guard owns (the activity stack), so
/// a task that never ends cleanly can't hold the sweep for good.
pub fn sweep_yields_to(&self, busy: Busy) {
lock(&self.state).busy = Some(busy);
}
/// The existence check itself (blocking: for worker threads, e.g. the Rename preview's).
pub fn probe(&self) -> ExistsProbe {
self.probe.clone()
@@ -99,12 +133,7 @@ impl Availability {
if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) {
st.queued.insert(path.to_string());
st.queue.push_back(path.to_string());
if !st.worker {
st.worker = true;
drop(st);
self.spawn_worker(wake.clone());
return cached.map(|c| c.0);
}
self.start(st, wake);
}
cached.map(|c| c.0)
}
@@ -114,9 +143,10 @@ impl Availability {
self.exists(path) == Some(false)
}
/// Changes whenever a cached answer changes.
pub fn generation(&self) -> u64 {
lock(&self.state).generation
/// Changes whenever a file is found gone or found back: what [`Availability::is_offline`]
/// says changed for some file. (A file found there at its first check changes nothing.)
pub fn gone_generation(&self) -> u64 {
lock(&self.state).gone_generation
}
/// Paths waiting for the worker.
@@ -124,37 +154,123 @@ impl Availability {
lock(&self.state).queue.len()
}
/// Check `paths` too, in the background and after whatever is asked meanwhile, skipping those
/// checked less than [`RECHECK`] ago. One sweep at a time: `false` (and nothing queued) while
/// one is under way, or in synchronous mode. A sweep's answers wake only when a file is found
/// gone or back.
pub fn sweep(&self, paths: Vec<String>) -> bool {
self.sweep_checked(None, paths)
}
fn sweep_checked(&self, epoch: Option<u64>, paths: Vec<String>) -> bool {
let Some(wake) = &self.background else { return false };
let mut st = lock(&self.state);
if !st.sweep.is_empty() || epoch.is_some_and(|e| e != st.sweep_epoch) {
return false;
}
st.sweep = paths.into();
self.start(st, wake);
true
}
/// Drop what the sweep under way has left (another library was opened: its files are not the
/// ones to check now) and say which sweeps may start from here ([`Availability::sweep_in`]): a
/// list of files being made for the old one is then refused.
pub fn restart_sweeps(&self) -> u64 {
let mut st = lock(&self.state);
st.sweep.clear();
st.sweep_epoch += 1;
st.sweep_epoch
}
/// [`Availability::sweep`], unless [`Availability::restart_sweeps`] was called since it
/// returned `epoch`.
pub fn sweep_in(&self, epoch: u64, paths: Vec<String>) -> bool {
self.sweep_checked(Some(epoch), paths)
}
/// Whether the file at `path` was found gone (and not found there since): `false` for a file
/// not checked yet.
pub fn is_gone(&self, path: &str) -> bool {
lock(&self.state).gone.contains(path)
}
/// How many files are found gone.
pub fn gone_len(&self) -> usize {
lock(&self.state).gone.len()
}
/// What the sweep under way has left to check (0: none under way, though its last file may still
/// be in flight).
pub fn sweeping(&self) -> usize {
lock(&self.state).sweep.len()
}
/// Start the worker, unless it runs.
fn start(&self, mut st: MutexGuard<'_, State>, wake: &Waker) {
if !st.worker {
st.worker = true;
drop(st);
self.spawn_worker(wake.clone());
}
}
fn spawn_worker(&self, wake: Waker) {
let (state, probe) = (self.state.clone(), self.probe.clone());
let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || {
loop {
let path = {
// what was asked first; a sweep's next file when nothing is (unless it is asked
// about too, or was checked a moment ago)
let (path, asked) = {
let mut st = lock(&state);
let Some(p) = st.queue.pop_front() else {
st.worker = false;
return;
};
p
let giving_way = if st.queue.is_empty() && !st.sweep.is_empty() { st.busy.clone() } else { None };
if let Some(busy) = giving_way {
drop(st);
if busy() {
// (the worker stays: a question asked meanwhile is answered within YIELD_POLL)
std::thread::sleep(YIELD_POLL);
continue;
}
st = lock(&state);
}
match st.queue.pop_front() {
Some(p) => (p, true),
None => loop {
let Some(p) = st.sweep.pop_front() else {
st.worker = false;
return;
};
let fresh = st.known.get(&p).is_some_and(|(_, at)| at.elapsed() < RECHECK);
if !fresh && !st.queued.contains(&p) {
break (p, false);
}
},
}
};
let ok = probe(&path);
let changed = {
let wakes = {
let mut st = lock(&state);
st.queued.remove(&path);
if asked {
st.queued.remove(&path);
}
let gone_changed = if ok { st.gone.remove(&path) } else { st.gone.insert(path.clone()) };
let prev = st.known.insert(path, (ok, std::time::Instant::now()));
let changed = prev.map(|p| p.0) != Some(ok);
if changed {
st.generation += 1;
if gone_changed {
st.gone_generation += 1;
}
changed
(asked && changed) || gone_changed
};
if changed {
if wakes {
wake();
}
}
});
if started.is_err() {
// try again on the next question
lock(&self.state).worker = false;
// try again on the next question; a sweep nobody works on would refuse every new one
let mut st = lock(&self.state);
st.worker = false;
st.sweep.clear();
}
}
}
@@ -210,6 +326,209 @@ mod tests {
assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread");
assert!(woke.load(Ordering::SeqCst) >= 2);
assert!(a.generation() >= 2);
assert!(a.gone_generation() >= 2);
}
fn wait_until(what: &str, done: impl Fn() -> bool) {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while !done() {
assert!(std::time::Instant::now() < deadline, "timed out waiting: {what}");
std::thread::sleep(Duration::from_millis(2));
}
}
fn held<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A background `Availability` whose probe logs what it checks; files named `gone…` are not there.
fn logged() -> (Availability, Arc<Mutex<Vec<String>>>, Arc<AtomicUsize>) {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::new()));
let l = log.clone();
a.set_probe(Arc::new(move |p| {
held(&l).push(p.to_string());
!p.starts_with("gone")
}));
let woke = Arc::new(AtomicUsize::new(0));
let w = woke.clone();
a.run_in_background(Arc::new(move || {
w.fetch_add(1, Ordering::SeqCst);
}));
(a, log, woke)
}
#[test]
fn the_files_a_sweep_finds_gone_are_counted() {
let (a, _, _) = logged();
assert!(a.sweep(vec!["here".into(), "gone 1".into(), "also here".into(), "gone 2".into()]));
wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0);
wait_until("the answers", || a.gone_len() == 2);
assert!(a.is_gone("gone 1") && a.is_gone("gone 2") && !a.is_gone("here") && !a.is_gone("also here"));
}
#[test]
fn files_found_there_at_first_look_change_nothing_for_missing_photos() {
let (a, _, woke) = logged();
let before = a.gone_generation();
a.sweep(vec!["a".into(), "b".into(), "c".into()]);
wait_until("the answers", || ["a", "b", "c"].iter().all(|p| lock(&a.state).known.contains_key(*p)));
assert_eq!(a.gone_generation(), before, "nothing went missing");
assert_eq!(woke.load(Ordering::SeqCst), 0, "a sweep finding files there wakes nobody");
a.sweep(vec!["gone".into()]);
wait_until("the gone file", || a.gone_generation() != before);
wait_until("the wake", || woke.load(Ordering::SeqCst) == 1);
}
#[test]
fn what_is_asked_is_checked_before_the_rest_of_a_sweep_and_one_sweep_runs_at_a_time() {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::<String>::new()));
// the first file of the sweep holds the worker until the test has asked about another
let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
let (l, g) = (log.clone(), gate.clone());
a.set_probe(Arc::new(move |p| {
if p == "sweep 0" {
let (open, cv) = &*g;
let mut open = held(open);
while !*open {
open = cv.wait(open).unwrap_or_else(PoisonError::into_inner);
}
}
held(&l).push(p.to_string());
true
}));
a.run_in_background(Arc::new(|| {}));
assert!(a.sweep((0..50).map(|i| format!("sweep {i}")).collect()));
assert!(!a.sweep(vec!["another".into()]), "a sweep under way: the next one waits for its turn");
assert_eq!(a.exists("asked"), None, "not known yet");
*held(&gate.0) = true;
gate.1.notify_all();
wait_until("the log", || held(&log).len() == 51);
let log = held(&log);
// (at most the file the worker was already checking comes first)
assert!(matches!(log.iter().position(|p| p == "asked"), Some(0 | 1)), "asked next, ahead of the sweep: {log:?}");
assert!(!log.iter().any(|p| p == "another"));
}
#[test]
fn a_new_sweep_replaces_the_one_under_way_and_a_list_made_for_the_old_one_is_refused() {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::<String>::new()));
// the old sweep's first file holds the worker until the new one is in place
let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
let (l, g) = (log.clone(), gate.clone());
a.set_probe(Arc::new(move |p| {
if p == "old 0" {
let (open, cv) = &*g;
let mut open = held(open);
while !*open {
open = cv.wait(open).unwrap_or_else(PoisonError::into_inner);
}
}
held(&l).push(p.to_string());
true
}));
a.run_in_background(Arc::new(|| {}));
let old = a.restart_sweeps();
assert!(a.sweep_in(old, (0..20).map(|i| format!("old {i}")).collect()));
wait_until("the worker takes the first file", || a.sweeping() == 19);
// another library is opened: what is left of the old sweep goes, and a list still being made for it too
let new = a.restart_sweeps();
assert_eq!(a.sweeping(), 0);
assert!(!a.sweep_in(old, vec!["old late".into()]), "a list made for the old library is refused");
assert!(a.sweep_in(new, (0..3).map(|i| format!("new {i}")).collect()));
*held(&gate.0) = true;
gate.1.notify_all();
wait_until("the new sweep", || held(&log).len() == 4);
assert_eq!(*held(&log), ["old 0", "new 0", "new 1", "new 2"]);
}
#[test]
fn files_found_gone_are_told_apart_from_files_never_checked() {
let (a, _, _) = logged();
assert!(!a.is_gone("gone 1") && a.gone_len() == 0);
assert!(a.sweep(vec!["gone 1".into(), "here".into()]));
wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0 && a.gone_len() == 1);
assert!(a.is_gone("gone 1") && !a.is_gone("here") && !a.is_gone("never asked"));
}
fn busy_flag(a: &Availability) -> Arc<std::sync::atomic::AtomicBool> {
let busy = Arc::new(std::sync::atomic::AtomicBool::new(true));
let b = busy.clone();
a.sweep_yields_to(Arc::new(move || b.load(Ordering::SeqCst)));
busy
}
#[test]
fn a_sweep_gives_way_to_file_work_answers_what_is_asked_and_resumes_after() {
let (a, log, _) = logged();
let busy = busy_flag(&a);
assert!(a.sweep((0..20).map(|i| format!("sweep {i}")).collect()));
std::thread::sleep(YIELD_POLL * 3);
assert!(held(&log).is_empty(), "no sweep check while files are busy");
assert_eq!(a.sweeping(), 20, "nothing of the sweep is lost");
assert_eq!(a.exists("asked"), None);
wait_until("the answer", || a.exists("asked") == Some(true));
assert_eq!(*held(&log), ["asked"], "what is asked is answered, the sweep still waits");
busy.store(false, Ordering::SeqCst);
wait_until("the sweep", || a.sweeping() == 0);
wait_until("the log", || held(&log).len() == 21);
// it went on from its first file, not from scratch
assert_eq!(held(&log).iter().filter(|p| *p == "sweep 0").count(), 1);
}
#[test]
fn a_sweep_stopped_part_way_goes_on_from_there() {
let mut a = Availability::default();
let log = Arc::new(Mutex::new(Vec::<String>::new()));
let l = log.clone();
// the worker is held inside the fourth check until the sweep is told to give way
let hold = Arc::new(std::sync::atomic::AtomicBool::new(true));
let h = hold.clone();
a.set_probe(Arc::new(move |p| {
while p == "sweep 3" && h.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(1));
}
held(&l).push(p.to_string());
true
}));
a.run_in_background(Arc::new(|| {}));
let busy = busy_flag(&a);
busy.store(false, Ordering::SeqCst);
assert!(a.sweep((0..100).map(|i| format!("sweep {i}")).collect()));
wait_until("a few checks", || held(&log).len() >= 3);
busy.store(true, Ordering::SeqCst);
hold.store(false, Ordering::SeqCst);
std::thread::sleep(YIELD_POLL * 2);
let stopped = held(&log).len();
assert_eq!(stopped, 4, "stopped part way, after the check it was in");
std::thread::sleep(YIELD_POLL * 2);
assert_eq!(held(&log).len(), stopped, "paused");
assert_eq!(a.sweeping(), 100 - stopped, "what is left is kept");
busy.store(false, Ordering::SeqCst);
wait_until("the sweep", || a.sweeping() == 0 && held(&log).len() == 100);
let mut all = held(&log).clone();
all.sort();
all.dedup();
assert_eq!(all.len(), 100, "every file checked once, none again from the start");
}
#[test]
fn a_sweep_that_yields_to_nothing_runs_as_before() {
let (a, log, _) = logged();
assert!(a.sweep(vec!["a".into(), "b".into()]));
wait_until("the sweep", || held(&log).len() == 2);
}
#[test]
fn a_sweep_skips_files_checked_a_moment_ago() {
let (a, log, _) = logged();
assert_eq!(a.exists("a"), None);
wait_until("the answer", || a.exists("a").is_some());
a.sweep(vec!["a".into(), "b".into()]);
wait_until("b", || held(&log).iter().any(|p| p == "b"));
wait_until("the sweep", || a.sweeping() == 0);
assert_eq!(held(&log).iter().filter(|p| *p == "a").count(), 1, "`a` was checked a moment ago");
}
}
+26 -7
View File
@@ -9,10 +9,12 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, cmd};
use crate::{Result, Session};
/// Seconds between shots of the same burst at most.
const BURST_GAP: i64 = 10;
/// Signature similarity for "the same scene".
const SAME: f32 = 0.93;
/// Seconds between shots of the same burst at most (default of `burstGap`).
const BURST_GAP: f64 = 10.0;
/// Signature similarity for "the same scene" (default of `similarity`).
const SAME: f64 = 0.93;
/// The longest `burstGap` accepted: a day.
const MAX_GAP: f64 = 86_400.0;
fn secs(iso: &str) -> Option<i64> {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
@@ -26,6 +28,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
let (gap, same) = burst_params(p)?;
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
let mut failed = Vec::new();
@@ -45,8 +48,8 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let mut next = 1u32;
for i in 1..rows.len() {
let (a, b) = (&rows[i - 1], &rows[i]);
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y.saturating_sub(x)).unsigned_abs() as f64 <= gap);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= same {
let g = *group_of[i - 1].get_or_insert_with(|| {
next += 1;
next - 1
@@ -86,6 +89,22 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
}
/// `burstGap` (seconds, 0..=a day) and `similarity` (0..=1) of `photo.analyze`, refused before
/// anything is measured when they are not numbers in range.
fn burst_params(p: &Value) -> Result<(f64, f32)> {
const C: &str = "photo.analyze";
let num = |key: &str, d: f64, max: f64| -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(d),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (0.0..=max).contains(&x) => Ok(x),
_ => Err(super::bad(C, format!("`{key}` must be a number from 0 to {max}"))),
},
}
};
Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32))
}
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
@@ -139,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assisted Culling",
[],
None,
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool, burstGap?: seconds 0..86400 (10), similarity?: 0..1 (0.93)} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots (signature similarity at least `similarity`) taken at most `burstGap` seconds apart and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
always,
analyze
),
+4 -1
View File
@@ -451,7 +451,10 @@ pub fn specs() -> Vec<CommandSpec> {
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
Some(path) => {
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
s.check_write_target(path).map_err(|e| bad("keyword.export", e))?;
// A folder that isn't there is an error, not created.
lightcraft_catalog::safe_file::write_atomic(std::path::Path::new(path), file.text.as_bytes())
.map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
r["path"] = json!(path);
}
None => r["text"] = json!(file.text),
+123 -43
View File
@@ -7,6 +7,9 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it).
/// Its callers end an open edit (a slider drag) before their own change, as moving to the next
/// photo does, so it is its own undo step and comes first.
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
@@ -201,7 +204,6 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
@@ -220,10 +222,36 @@ fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> F
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar,
/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is
/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so
/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey
/// keep showing the photo they rate.
fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null()));
// the view as it was, then an open drag ends first, as its own undo step (the selection may
// move on; the drag itself can take the photo out of the view)
let before = (!named).then(|| s.visible_shared().1);
if !named {
s.end_interaction()?;
}
let active = s.selection.active;
let v = edit(s)?;
if let Some(before) = before
&& active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a))
{
select_after_deletion(s, &before);
s.active_mask = None;
s.active_spot = None;
} else {
advance_if(s, p);
}
Ok(v)
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
@@ -764,40 +792,66 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| { step(s, -row_step(p)) }
),
// ---- rating / flags / labels
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
if !(0..=5).contains(&r) {
return Err(bad("photo.rate", "rating must be 0..5"));
cmd!(
"photo.rate",
"Set Rating",
["Photo", "Set Rating"],
None,
"{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
if !(0..=5).contains(&r) {
return Err(bad("photo.rate", "rating must be 0..5"));
}
cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 })))
}
let v = for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))?;
advance_if(s, p);
Ok(v)
}),
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, -1, "Decrease Rating")
}),
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, 1, "Increase Rating")
}),
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
advance_if(s, p);
Ok(v)
}),
),
cmd!(
"photo.decreaseRating",
"Decrease Rating",
[],
None,
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) }
),
cmd!(
"photo.increaseRating",
"Increase Rating",
[],
None,
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) }
),
cmd!(
"photo.flag",
"Set Flag",
["Photo", "Set Flag"],
None,
"{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f })))
}
),
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
cmd!(
"photo.cycleFlag",
"Cycle Flag",
["Photo", "Set Flag"],
Some("`"),
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
cull(s, p, |s| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
})
})
}
),
@@ -806,34 +860,52 @@ pub fn specs() -> Vec<CommandSpec> {
"Increase Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Up"),
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
|s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) }
),
cmd!(
"photo.decreaseFlag",
"Decrease Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Down"),
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
|s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) }
),
cmd!(
"photo.pick",
"Flag as Pick",
["Photo", "Set Flag"],
Some("P"),
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) }
),
cmd!(
"photo.reject",
"Flag as Reject",
["Photo", "Set Flag"],
Some("X"),
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) }
),
cmd!(
"photo.unflag",
"Unflag",
["Photo", "Set Flag"],
Some("U"),
"{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) }
),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
cmd!("photo.reject", "Flag as Reject", ["Photo", "Set Flag"], Some("X"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))
}),
cmd!("photo.unflag", "Unflag", ["Photo", "Set Flag"], Some("U"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))
}),
cmd!(
"photo.label",
"Set Color Label",
["Photo", "Set Color Label"],
None,
"{label: red|yellow|green|blue|purple|none, ids?}",
"{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view",
has_selection,
|s, p| {
// `none` (or null) clears; a missing label or one that isn't text is an error, not a clear
@@ -844,7 +916,7 @@ pub fn specs() -> Vec<CommandSpec> {
None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")),
Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")),
};
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l })))
}
),
// ---- orientation
@@ -915,6 +987,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let targets = s.targets(p)?;
// an open drag ends first, as its own undo step (the selection moves on)
s.end_interaction()?;
let n = mark_deleted(s, &targets, "Delete")?;
select_after_deletion(s, &before);
Ok(json!({"changed": n}))
@@ -944,6 +1018,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let before = s.visible_cloned();
let t = s.targets(p)?;
// before the inverse ops are taken, so undo brings the photo back with its edit
s.end_interaction()?;
let op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
select_after_deletion(s, &before);
@@ -1635,7 +1711,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.compact_library()?;
ok()
}),
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| {
s.media.rendered.clear();
s.media.clear_sources();
ok()
@@ -1804,6 +1880,10 @@ mod relative_rating_tests;
#[path = "library/tests_flag_cycle.rs"]
mod flag_cycle_tests;
#[cfg(test)]
#[path = "library/tests_rated_out.rs"]
mod rated_out_tests;
#[cfg(test)]
#[path = "library/tests_enable_filters.rs"]
mod enable_filters_tests;
@@ -0,0 +1,139 @@
//! A rating, flag or label that takes the active photo out of the view (the filter bar, Picks) goes on from its place:
//! the photo that took it, not the view's first photo (#2312).
use lightcraft_catalog::{ColorLabel, Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
/// 100 photos with one star each, in file-name order (photo `n` is the `n`th), the filter showing one-star photos.
fn one_star() -> Result<Session> {
let mut s = Session::new();
for id in 1..=100 {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-rated-out/{id:03}.jpg") },
&format!("{id:03}.jpg"),
"JPEG",
40,
30,
"2026-10-19",
);
photo.rating = 1;
photo.flag = Flag::Pick;
photo.label = Some(ColorLabel::Red);
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"}))?;
Ok(s)
}
#[test]
fn a_star_taken_off_goes_on_to_the_next_photo() {
// Given photo 82 of 100 one-star photos, the filter showing one-star photos
let mut s = one_star().unwrap();
s.execute("library.select", &json!({"ids": [82]})).unwrap();
// When its star is taken off, Then it leaves the view and the photo after it is the one selected
s.execute("photo.rate", &json!({"rating": 0})).unwrap();
assert!(!s.visible_cloned().contains(&PhotoId(82)));
assert_eq!(s.selection, Selection::single(PhotoId(83)));
// and the next photo is the one after that, not the first one of the view
s.execute("library.next", &json!({})).unwrap();
assert_eq!(s.active(), Some(PhotoId(84)));
// With `advance`, the photo that took its place is the next one: no photo is skipped
s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(85)));
s.execute("photo.decreaseRating", &json!({"advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(86)));
// The last photo of the view hands on to the one before it
s.execute("library.select", &json!({"ids": [100]})).unwrap();
s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(99)));
// A rating that keeps the photo in the view advances as before
s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(99)), "the last photo of the view stays");
s.execute("library.select", &json!({"ids": [10]})).unwrap();
s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(11)));
}
#[test]
fn flags_and_labels_that_leave_the_view_go_on_to_the_next_photo_too() {
for (filter, command, params) in [
(json!({"flag": "pick"}), "photo.reject", json!({})),
(json!({"flag": "pick"}), "photo.flag", json!({"flag": "none"})),
(json!({"flag": "pick"}), "photo.cycleFlag", json!({})),
(json!({"flag": "pick"}), "photo.decreaseFlag", json!({})),
(json!({"label": "red"}), "photo.label", json!({"label": "none"})),
(json!({}), "photo.increaseRating", json!({})),
] {
let mut s = one_star().unwrap();
s.execute("library.filter", &filter).unwrap();
s.execute("library.select", &json!({"ids": [40]})).unwrap();
s.execute(command, &params).unwrap();
assert!(!s.visible_cloned().contains(&PhotoId(40)), "{command} {params}");
assert_eq!(s.selection, Selection::single(PhotoId(41)), "{command} {params}");
}
}
#[test]
fn several_photos_rated_out_go_on_after_the_active_one() {
let mut s = one_star().unwrap();
// three selected, the middle one active: all leave, the photo after the active one is selected
s.execute("library.select", &json!({"ids": [20, 21, 22], "active": 21})).unwrap();
s.execute("photo.rate", &json!({"rating": 0})).unwrap();
assert_eq!(s.selection, Selection::single(PhotoId(23)));
// the active one leaves and another selected photo stays: that one is active
s.execute("photo.rate", &json!({"ids": [30], "rating": 3})).unwrap();
s.execute("library.filter", &json!({"rating": 1, "ratingOp": "atLeast"})).unwrap();
s.execute("library.select", &json!({"ids": [29, 30], "active": 29})).unwrap();
s.execute("photo.decreaseRating", &json!({})).unwrap();
assert_eq!(s.selection, Selection { ids: vec![PhotoId(30)], active: Some(PhotoId(30)) });
}
#[test]
fn named_photos_and_a_held_view_keep_the_selection() {
// Photos named by `ids` leave the selection to the caller (Compare and Survey keep the photo they show)
let mut s = one_star().unwrap();
s.execute("library.select", &json!({"ids": [5, 6], "active": 6})).unwrap();
s.execute("photo.rate", &json!({"ids": [6], "rating": 0})).unwrap();
assert_eq!(s.selection, Selection { ids: vec![PhotoId(5), PhotoId(6)], active: Some(PhotoId(6)) });
// A held view keeps the photo, so the selection stays with it
let mut s = one_star().unwrap();
s.execute("library.holdView", &json!({"on": true})).unwrap();
// (what the grid shows is what is held)
assert_eq!(s.visible_cloned().len(), 100);
s.execute("library.select", &json!({"ids": [7]})).unwrap();
s.execute("photo.rate", &json!({"rating": 0})).unwrap();
assert!(s.visible_cloned().contains(&PhotoId(7)));
assert_eq!(s.selection, Selection::single(PhotoId(7)));
s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(8)));
}
/// A slider drag still open when a rating takes the photo out of the view, or when the photo is
/// deleted, ends as its own undo step before that change, as moving to the next photo does: undo
/// brings the rating or the photo back first, then the edit. Before, the move left the drag open
/// with no undo step (CodeRabbit on #2689).
#[test]
fn an_open_edit_ends_before_the_selection_moves_on() {
let exposure = |s: &Session, id| s.develop_of(PhotoId(id)).map(|d| d.light.exposure);
for (command, params) in [("photo.rate", json!({"rating": 0})), ("photo.delete", json!({})), ("photo.deletePermanently", json!({}))] {
let mut s = one_star().unwrap();
s.execute("library.select", &json!({"ids": [82]})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
s.execute(command, &params).unwrap();
assert_eq!(s.active(), Some(PhotoId(83)), "{command}");
assert!(s.interaction.is_none(), "{command}: the drag ended with the move");
if command != "photo.deletePermanently" {
assert_eq!(exposure(&s, 82), Some(1.5), "{command}");
}
// undo: the rating or the photo comes back first, with the edit; then the edit goes
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.visible_cloned().contains(&PhotoId(82)), "{command}: back in the view");
assert_eq!(exposure(&s, 82), Some(1.5), "{command}: with its edit");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(exposure(&s, 82), Some(0.0), "{command}");
}
}
+20 -5
View File
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Capture Time",
[],
None,
"{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}",
"{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..], unreadable: photos skipped because their capture time can't be read}",
has_selection,
|s, p| {
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
@@ -213,12 +213,25 @@ pub fn specs() -> Vec<CommandSpec> {
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
.and_then(|_| iso_seconds(&anchor_date))
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
delta += iso_seconds(&t).unwrap_or(from) - from;
// the others move by the anchor's change, so it must read in full, not just
// its leading digits (a Lightroom catalog's capture time is copied verbatim);
// alone, it simply gets the time, which repairs it
if shift_iso(&anchor_date, 0).is_some() {
delta += iso_seconds(&t).unwrap_or(from) - from;
} else if targets.len() == 1 {
each = Some(t);
} else {
return Err(bad(
c,
"the active photo's capture time can't be read, so the other photos can't move by the same amount; set its time with it selected alone",
));
}
}
}
let mut ops = Vec::new();
let mut out = Vec::new();
let mut out_of_range = 0usize;
let mut unreadable = 0usize;
for id in &targets {
let from = match &each {
Some(t) => t.clone(),
@@ -226,9 +239,11 @@ pub fn specs() -> Vec<CommandSpec> {
};
let Some(new) = shift_iso(&from, delta) else {
// a date that reads but would leave years 0000–9999 is refused below; one
// that doesn't read is skipped, as before
if iso_seconds(&from).is_some() {
// that doesn't read is skipped and counted
if shift_iso(&from, 0).is_some() {
out_of_range += 1;
} else {
unreadable += 1;
}
continue;
};
@@ -244,7 +259,7 @@ pub fn specs() -> Vec<CommandSpec> {
if n > 0 {
s.commit("Edit Capture Time", Op::Batch { ops })?;
}
Ok(json!({"changed": n, "captured": out}))
Ok(json!({"changed": n, "captured": out, "unreadable": unreadable}))
}
),
cmd!(
+9 -5
View File
@@ -1,6 +1,8 @@
//! Read-only queries (not journaled): catalog, photos, develop state, controls, presets, albums.
use lightcraft_catalog::{Album, Photo, PhotoId};
use std::collections::HashMap;
use lightcraft_catalog::{Album, AlbumId, Photo, PhotoId};
use lightcraft_develop::{CONTROLS, controls};
use serde_json::{Value, json};
@@ -36,9 +38,9 @@ pub fn photo_summary(p: &Photo) -> Value {
/// instead of overflowing the stack.
const MAX_ALBUM_DEPTH: usize = 64;
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
fn album_json(a: &Album, all: &[Album], counts: &HashMap<AlbumId, usize>, cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
let children: Vec<Value> = if depth < MAX_ALBUM_DEPTH {
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect()
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, counts, cat, depth + 1)).collect()
} else {
Vec::new()
};
@@ -46,7 +48,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth
"id": a.id.0,
"name": a.name,
"folder": a.folder,
"count": cat.album_count(a.id),
"count": counts.get(&a.id).copied().unwrap_or(0),
"cover": a.cover.map(|c| c.0),
"children": children,
});
@@ -152,7 +154,9 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect()))
// every album counted in one pass, so a smart album tested by others is worked out once
let counts = lightcraft_catalog::tally::AlbumTally::of(&s.catalog);
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, counts.counts(), &s.catalog, 0)).collect()))
}),
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "photo.inspect")?;
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn load(s: &mut Session) {
..Default::default()
};
// one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so
// View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file
// View ▸ Focus Point and Zoom to Focus Point can be tried without a camera file
if sc.name == "Cosmos bloom" {
p.meta.focus = Some(lightcraft_meta::FocusPoint {
rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 },
+174 -34
View File
@@ -501,11 +501,12 @@ pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
}
/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the
/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded.
/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it.
/// The error says why it couldn't be read or decoded.
pub fn read_graphic(path: &str) -> Result<Rgba8, String> {
let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?;
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?;
Ok(decoded.to_srgb8())
Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation)))
}
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
@@ -518,20 +519,27 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
return;
}
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
let turns = wm.quarter_turns();
// Reserve the inset on anchored axes before fitting the turned box. Centered axes
// ignore insets, just as placement below does. One scale preserves the graphic's shape.
let short = width.min(height) as f32;
let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short);
use Anchor::*;
let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 };
let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 };
let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) };
let scale =
(wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64);
let lw = (logo.width as f64 * scale).round().max(1.0) as usize;
let lh = (logo.height as f64 * scale).round().max(1.0) as usize;
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
let p = logo.get(x, y);
let a = p[3] as f32 / 255.0;
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
});
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
let short = width.min(height) as f32;
let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short);
let turns = wm.quarter_turns();
let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) };
let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => ix,
Top | Center | Bottom => (w - lwf) / 2.0,
@@ -658,8 +666,8 @@ pub struct ExportOptions {
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
pub bit_depth: Option<u8>,
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
/// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
/// rendition plus a gain map, so the file looks right everywhere; `limit_kb` includes both
/// images and their metadata), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
/// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float
/// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR
/// edit, are SDR.
@@ -1392,29 +1400,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta);
match o.limit_kb {
Some(kb) => {
let limit = kb as usize * 1024;
// Binary search for the highest quality that fits.
let (mut lo, mut hi) = (1u8, o.quality);
let mut best = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let b = jpeg(mid).map_err(|e| e.to_string())?;
if b.len() <= limit {
best = Some(b);
lo = mid + 1;
} else if mid == 1 {
break;
} else {
hi = mid - 1;
}
}
return best.ok_or_else(|| format!("cannot fit within {kb} KB"));
}
None => jpeg(o.quality),
}
return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta));
}
ExportFormat::Png => encode::encode_png(&e, &meta),
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
@@ -1428,6 +1414,27 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
r.map_err(|e| e.to_string())
}
/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs.
fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result<Vec<u8>>) -> Result<Vec<u8>, String> {
let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) };
let limit = u64::from(kb) * 1024;
let (mut lo, mut hi) = (1u8, o.quality.min(100));
let mut best = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let b = jpeg(mid).map_err(|e| e.to_string())?;
if b.len() as u64 <= limit {
best = Some(b);
lo = mid + 1;
} else if mid == 1 {
break;
} else {
hi = mid - 1;
}
}
best.ok_or_else(|| format!("cannot fit within {kb} KB"))
}
/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`.
fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {
let mut img = img.clone();
@@ -1475,7 +1482,7 @@ pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, met
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string())
encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em))
}
/// The codecs' name of an output space (for its ICC profile).
@@ -2429,6 +2436,139 @@ mod tests {
assert!(b.len() <= kb as usize * 1024);
}
#[test]
fn hdr_jpeg_size_limit_respected() {
let img = Rgba8::from_fn(256, 192, |x, y| {
let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663);
[n as u8, (n >> 8) as u8, (n >> 16) as u8, 255]
});
let hdr = DeepImage {
width: img.width,
height: img.height,
space: OutputSpace::Srgb,
samples: DeepSamples::F32(
img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(),
),
};
let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() };
let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap();
let kb = (full.len() / 2048) as u32;
let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap();
assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len());
let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap();
assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting");
let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None);
assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB")));
}
#[test]
fn graphic_watermark_honours_exif_orientation() {
let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] });
for orientation in [2, 6] {
let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() };
let exif = lightcraft_meta::write_exif(&meta);
let bytes = lightcraft_codecs::encode_jpeg(
&EncodeImage::rgba8(&logo),
100,
ChromaSubsampling::S444,
&EncodeMeta { exif: Some(&exif), ..Default::default() },
)
.unwrap();
let path = dir.join(format!("oriented-{orientation}.jpg"));
std::fs::write(&path, bytes).unwrap();
let wm = Watermark {
image: path.to_string_lossy().into(),
image_width: if orientation == 6 { 0.25 } else { 0.5 },
opacity: 1.0,
inset_x: 0.0,
inset_y: 0.0,
..Default::default()
};
let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
if orientation == 6 {
assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue");
} else {
assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red");
}
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() {
let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tall.png");
let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]);
std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap();
for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] {
let wm = Watermark {
image: path.to_string_lossy().into(),
image_width: 0.5,
opacity: 1.0,
inset_x: 0.0,
inset_y: 0.0,
rotation,
..Default::default()
};
let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect();
let bounds = (
lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1,
lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1,
);
assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn graphic_watermark_fit_preserves_anchor_insets() {
let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tall.png");
let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]);
std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap();
use Anchor::*;
for (anchor, rotation, expected) in [
(BottomRight, 90, (0, 44, 111, 71)),
(TopLeft, 270, (8, 8, 119, 35)),
(Right, 90, (0, 26, 111, 53)),
(BottomRight, 0, (94, 0, 111, 71)),
(TopLeft, 180, (8, 8, 25, 79)),
(Bottom, 0, (51, 0, 68, 71)),
// Centered axes ignore their inset rather than shrinking the image unnecessarily.
(Center, 90, (0, 25, 119, 54)),
(Top, 90, (0, 8, 119, 37)),
] {
let wm = Watermark {
image: path.to_string_lossy().into(),
image_width: 0.5,
opacity: 1.0,
inset_x: 0.1,
inset_y: 0.1,
rotation,
anchor,
..Default::default()
};
let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect();
let bounds = (
lit.iter().map(|p| p.0).min().unwrap(),
lit.iter().map(|p| p.1).min().unwrap(),
lit.iter().map(|p| p.0).max().unwrap(),
lit.iter().map(|p| p.1).max().unwrap(),
);
assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn sharpen_increases_edge_contrast() {
let mut img = Rgba8::new(8, 8);
+82
View File
@@ -374,6 +374,42 @@ pub fn is_supported(path: &Path) -> bool {
path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()))
}
/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension
/// is listed in both cases: browsers' `accept` matching is case-insensitive per
/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and
/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws
/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker.
///
/// The picker only offers files this build can actually import: `.avif` has no
/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs'
/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]).
/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised,
/// and import reports them as failed, saying why.
pub fn accept_list() -> String {
let heif_decodable = lightcraft_codecs::Format::Heif.can_decode();
let mut out = String::new();
for &ext in EXTENSIONS {
// Skip extensions a web user could pick but this build could not import.
let pickable = match ext {
"avif" => false,
"heic" | "heif" => heif_decodable,
_ => true,
};
if !pickable {
continue;
}
if !out.is_empty() {
out.push(',');
}
out.push('.');
out.push_str(ext);
out.push(',');
out.push('.');
out.push_str(&ext.to_uppercase());
}
out
}
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a
/// walk that stops at a bound is logged.
@@ -1607,6 +1643,52 @@ mod tests_known;
mod prepared_tests {
use super::*;
/// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the
/// files this build cannot import (see [`accept_list`]).
fn pickable_extensions() -> Vec<&'static str> {
let heif_decodable = lightcraft_codecs::Format::Heif.can_decode();
EXTENSIONS
.iter()
.copied()
.filter(|ext| match *ext {
"avif" => false,
"heic" | "heif" => heif_decodable,
_ => true,
})
.collect()
}
#[test]
fn accept_list_covers_every_pickable_extension_in_both_cases() {
let list = accept_list();
for ext in pickable_extensions() {
assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list");
assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase());
}
}
/// The web picker must not offer files this build cannot import: `.avif` has
/// no AV1 decoder in any build, so it is never listed, in either case.
#[test]
fn accept_list_never_offers_avif() {
let list = accept_list();
assert!(!list.contains(".avif"), "{list}");
assert!(!list.contains(".AVIF"), "{list}");
}
/// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases)
/// only when this build can decode them; otherwise hidden, so a web user
/// cannot pick a file that would fail to import.
#[test]
fn accept_list_offers_heic_heif_only_when_decodable() {
let list = accept_list();
let listed = lightcraft_codecs::Format::Heif.can_decode();
for ext in ["heic", "heif"] {
assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}");
assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}");
}
}
#[test]
fn revalidate_add_only_converts_matching_ready_item() {
let mut s = Session::new();
+3 -2
View File
@@ -1050,8 +1050,9 @@ impl Session {
};
let view = format!("{}|{rules}", key.1);
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
// the view fills in as the background checks find files gone (a file found there at
// its first check changes nothing it shows)
key.1.push_str(&format!("|{}", self.media.availability.gone_generation()));
}
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
+126 -3
View File
@@ -46,6 +46,41 @@ const PAPER: &[(&str, f64, f64)] = &[
("5x7", 5.0, 7.0),
("8x10", 8.0, 10.0),
("13x19", 13.0, 19.0),
// Japanese photo printing and postcard sizes, at their exact metric dimensions (the US photo
// sizes above are close but not identical: L is 89 x 127 mm, 5x7 in is 127 x 177.8 mm).
("l", 89.0 / 25.4, 127.0 / 25.4),
("2l", 127.0 / 25.4, 178.0 / 25.4),
("kg", 102.0 / 25.4, 152.0 / 25.4),
("postcard", 100.0 / 25.4, 148.0 / 25.4),
("double-postcard", 148.0 / 25.4, 200.0 / 25.4),
("6cut", 203.0 / 25.4, 254.0 / 25.4),
("4cut", 254.0 / 25.4, 305.0 / 25.4),
("business-card", 55.0 / 25.4, 91.0 / 25.4),
];
/// Japanese customary names (and a few English spellings) for the papers in [`PAPER`], matched
/// after [`normalize_paper`]: `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on.
const PAPER_ALIASES: &[(&str, &str)] = &[
("L判", "l"),
("Lサイズ", "l"),
("L-size", "l"),
("2L判", "2l"),
("2Lサイズ", "2l"),
("2L-size", "2l"),
("KG判", "kg"),
("KGサイズ", "kg"),
("KG-size", "kg"),
("はがき", "postcard"),
("ハガキ", "postcard"),
("葉書", "postcard"),
("ポストカード", "postcard"),
("往復はがき", "double-postcard"),
("往復ハガキ", "double-postcard"),
("六切", "6cut"),
("6切", "6cut"),
("四切", "4cut"),
("4切", "4cut"),
("名刺", "business-card"),
];
/// Names accepted by [`PageSetup::paper`] (besides `custom`).
@@ -53,10 +88,39 @@ pub fn paper_names() -> impl Iterator<Item = &'static str> {
PAPER.iter().map(|p| p.0)
}
/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`.
/// The form [`paper_name`] matches names in: lower case, letters and digits only, and `×`/`*` read
/// as `x`, so `L判`, `L判。`, `l-size` and `(l)` are one name and `4 × 6` is `4x6`.
fn normalize_paper(name: &str) -> String {
name.chars()
.filter_map(|c| match c {
'×' | '*' => Some('x'),
c if c.is_alphanumeric() => Some(c),
_ => None,
})
.collect::<String>()
.to_lowercase()
}
/// The canonical name of the paper `name` stands for, `None` for unknown names and `custom`.
/// `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on.
pub fn paper_name(name: &str) -> Option<&'static str> {
let n = normalize_paper(name);
if n.is_empty() {
return None;
}
PAPER
.iter()
.find(|p| normalize_paper(p.0) == n)
.map(|p| p.0)
.or_else(|| PAPER_ALIASES.iter().find(|(alias, _)| normalize_paper(alias) == n).map(|(_, name)| *name))
}
/// Portrait size in inches of a named paper, `None` for unknown names and `custom`. Case, spaces
/// and punctuation don't matter, and the Japanese customary names (`L判`, `2L判`, `KG判`, `はがき`,
/// `往復はがき`, `六切`, `四切`, `名刺`) are aliases of the papers above.
pub fn paper_size(name: &str) -> Option<(f64, f64)> {
let name = name.to_ascii_lowercase();
PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2))
let canonical = paper_name(name)?;
PAPER.iter().find(|p| p.0 == canonical).map(|p| (p.1, p.2))
}
/// An axis-aligned rectangle in inches.
@@ -935,6 +999,65 @@ mod tests {
assert!(p.size().is_err());
}
/// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as
/// aliases, and the ISO/US papers around them unchanged.
#[test]
fn japanese_photo_and_postcard_papers() {
let pt = |name: &str| {
let p = PageSetup { paper: name.into(), ..Default::default() };
p.size_points().unwrap_or_else(|e| panic!("{name}: {e}"))
};
for (name, w_mm, h_mm) in [
("L判", 89.0, 127.0),
("2L判", 127.0, 178.0),
("KG判", 102.0, 152.0),
("はがき", 100.0, 148.0),
("往復はがき", 148.0, 200.0),
("六切", 203.0, 254.0),
("四切", 254.0, 305.0),
("名刺", 55.0, 91.0),
] {
let (w, h) = pt(name);
let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0);
assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}");
assert!((w - h).abs() > 1e-6, "{name} is portrait");
}
// The customary and English spellings resolve to the same paper, including spaces and case.
for (a, b) in [
("L判", "l"),
("L判", " L "),
("L判", "L-size"),
("2L判", "2l"),
("KG判", "kg"),
("はがき", "postcard"),
("はがき", "ハガキ"),
("往復はがき", "double-postcard"),
("六切", "6cut"),
("六切", "6切"),
("四切", "4cut"),
("名刺", "business-card"),
("A4", "a 4"),
("4 × 6", "4x6"),
] {
assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}");
}
// Punctuation is ignored, including the periods and Japanese marks a pasted name may carry.
for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")]
{
assert_eq!(paper_name(name), Some(canonical), "{name}");
}
assert_eq!(paper_name("custom"), None);
assert!(paper_size("poster").is_none());
// The papers that were already there keep their sizes.
assert_eq!(pt("letter"), (612.0, 792.0));
assert_eq!(pt("4x6"), (288.0, 432.0));
assert_eq!(pt("5x7"), (360.0, 504.0));
assert_eq!(pt("8x10"), (576.0, 720.0));
assert_eq!(pt("13x19"), (936.0, 1368.0));
let (w, h) = pt("A4");
assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}");
}
#[test]
fn hostile_numbers_are_rejected() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] {
+18 -2
View File
@@ -4,7 +4,7 @@
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `%Y %y %m %B %b %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
//! typed. Characters that aren't allowed in file names become `-`. The original extension is always
//! kept.
@@ -25,6 +25,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use lightcraft_catalog::dates::MONTHS;
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde::Serialize;
@@ -57,9 +58,12 @@ fn sanitize(s: &str) -> String {
t.trim().trim_matches('.').trim().to_string()
}
/// `%Y%m%d`-style formatting of an ISO local time.
/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name
/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month
/// that doesn't parse gives nothing.
fn format_date(iso: &str, fmt: &str) -> String {
let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string();
let month = iso.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or("");
let mut out = String::new();
let mut it = fmt.chars();
while let Some(c) = it.next() {
@@ -71,6 +75,8 @@ fn format_date(iso: &str, fmt: &str) -> String {
Some('Y') => out.push_str(&part(0, 4)),
Some('y') => out.push_str(&part(2, 4)),
Some('m') => out.push_str(&part(5, 7)),
Some('B') => out.push_str(month),
Some('b') => out.push_str(month.get(..3).unwrap_or(month)),
Some('d') => out.push_str(&part(8, 10)),
Some('H') => out.push_str(&part(11, 13)),
Some('M') => out.push_str(&part(14, 16)),
@@ -120,6 +126,8 @@ pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
("%Y", "year, 4 digits"),
("%y", "year, 2 digits"),
("%m", "month 01–12"),
("%B", "month name (January)"),
("%b", "short month name (Jan)"),
("%d", "day 01–31"),
("%H", "hour 00–23"),
("%M", "minute"),
@@ -1670,6 +1678,8 @@ pub(crate) mod tests {
let mut p = sample_photo();
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]);
assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]);
// metadata can't add levels or climb out
p.meta.camera = "../../etc/x".into();
p.meta.title = "..".into();
@@ -1711,6 +1721,12 @@ pub(crate) mod tests {
let v = format_date("2026-01-14T05:58:48", d);
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
}
assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep");
assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026");
// a month that isn't 01–12 gives no name instead of panicking
for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] {
assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}");
}
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
let json = token_help_json();
+24
View File
@@ -313,6 +313,30 @@ fn curve_preset_export_never_overwrites_originals_or_sidecars() {
preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard");
}
/// #1944: Export Keywords went straight to `std::fs::write`, replacing a library original.
#[test]
fn keyword_export_never_overwrites_originals_or_sidecars() {
preset_export_preserves_originals("keyword.export", json!({}), "keyword-export-guard");
}
#[test]
fn keyword_export_preserves_the_previous_file_on_write_failure() {
let dir = temp_dir("keyword-export-failure");
let mut s = Session::with_demo();
let path = dir.join("keywords.txt");
let params = json!({"path": path.to_string_lossy()});
s.execute("keyword.export", &params).unwrap();
let previous = std::fs::read(&path).unwrap();
{
let _fault = lightcraft_catalog::safe_file::fail_writes_after(4);
assert!(s.execute("keyword.export", &params).is_err(), "keyword.export must report the failed write");
}
assert_eq!(std::fs::read(&path).unwrap(), previous, "the prior export is kept");
s.execute("keyword.export", &params).unwrap();
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no temporary files remain");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn preset_exports_preserve_previous_files_on_write_failure() {
let dir = temp_dir("preset-export-failure");
+48
View File
@@ -1068,6 +1068,54 @@ fn assisted_culling_groups_bursts_across_month_end() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Assisted culling: `burstGap` and `similarity` move the burst limits (#2275), and values out of
/// range are refused before anything is measured or stored.
#[test]
fn assisted_culling_burst_limits_are_adjustable() {
let dir = temp_dir("cull-limits");
for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
let img = lightcraft_scenes::demo_library()[0].render(w, h);
let data: Vec<[u8; 4]> = img
.data
.iter()
.map(|c| {
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let png = lightcraft_codecs::encode_png(
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
&Default::default(),
)
.unwrap();
std::fs::write(dir.join(name), png).unwrap();
}
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap();
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap();
// 15 s apart: past the default 10 s, inside a 20 s gap
assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0);
assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1);
// the look-alike pair is a burst at the default similarity, not when it must be identical
let strict = s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20, "similarity": 1.0})).unwrap();
assert_eq!(strict["groups"], 0, "{strict}");
// bad values change nothing
let undo_before = s.commits();
for bad in
[json!({"burstGap": -1}), json!({"burstGap": 86401}), json!({"burstGap": "10"}), json!({"similarity": 1.5}), json!({"similarity": -0.1})]
{
let mut p = bad.clone();
p["ids"] = json!(ids);
assert!(s.execute("photo.analyze", &p).is_err(), "{bad} must be refused");
}
assert_eq!(s.commits(), undo_before);
let _ = std::fs::remove_dir_all(&dir);
}
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
#[test]
fn dust_spots_are_found_and_healed() {
+55
View File
@@ -514,6 +514,61 @@ fn capture_time_shift_is_bounded() {
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged");
}
/// A capture time that doesn't fully read (a Lightroom catalog's `captureTime` is copied
/// verbatim) is left as it is and counted, not shifted into a clean-looking wrong time, and it
/// isn't mistaken for one that would leave years 0000–9999.
#[test]
fn capture_time_shift_counts_photos_whose_time_does_not_read() {
let mut s = crate::Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (good, bad) = (ids[0], ids[1]);
for (id, t) in [(good, "2026-01-01T10:00:00"), (bad, "2026-01-01T10:00:00 and more")] {
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
}
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 1})).unwrap();
assert_eq!(r["unreadable"], 1, "the unreadable photo is counted: {r}");
assert_eq!(r["changed"], 1, "{r}");
let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone();
assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T11:00:00"));
assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T10:00:00 and more"), "left as it was");
s.execute("edit.undo", &serde_json::json!({})).unwrap();
assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T10:00:00"), "one undo step");
// a shift past year 9999 for the readable photo is still the out-of-range refusal, and the
// unreadable one alone isn't
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "hours": 24 * 365 * 9000})).unwrap();
assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(0), Some(1)), "{r}");
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 24 * 365 * 9000}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("1 photo(s) outside")), "{r:?}");
}
/// Setting the active photo's time moves the others by its change, so an active photo whose own
/// time doesn't read can't anchor it: refused, nothing changes. Alone, or setting every photo's
/// time, it gets the time, which repairs it.
#[test]
fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() {
let mut s = crate::Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (bad, other) = (ids[0], ids[1]);
let broken = "2026-01-01T10:00:00 and more";
for (id, t) in [(bad, broken), (other, "2026-01-01T12:00:00")] {
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();
}
s.execute("library.select", &serde_json::json!({"ids": [bad.0, other.0], "active": bad.0})).unwrap();
assert_eq!(s.active(), Some(bad));
let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone();
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00"}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("can't be read")), "{r:?}");
assert_eq!(captured(&s, bad).as_deref(), Some(broken), "refused: unchanged");
assert_eq!(captured(&s, other).as_deref(), Some("2026-01-01T12:00:00"), "refused: the others don't move");
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "time": "2026-01-01T11:00:00"})).unwrap();
assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time");
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()) }).unwrap();
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00", "each": true})).unwrap();
assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(2), Some(0)), "{r}");
assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "each repairs it");
}
/// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is
/// refused with the rule's position, by album.createSmart and library.filter alike.
#[test]
+29 -1
View File
@@ -26,7 +26,13 @@ fn get_set_and_reset() {
assert_eq!(g["pageWidth"], 8.5);
assert_eq!(g["printable"]["x"], 0.25);
assert_eq!(g["settings"]["job"]["ppi"], 240.0);
assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4"));
let papers = g["papers"].as_array().unwrap();
assert!(papers.iter().any(|p| p["name"] == "a4"));
// The Japanese photo and postcard papers are listed with their metric sizes in inches.
let paper = |name: &str| papers.iter().find(|p| p["name"] == name).cloned().unwrap_or_else(|| panic!("{name}"));
let l = paper("l");
assert!((l["width"].as_f64().unwrap() - 89.0 / 25.4).abs() < 1e-9 && (l["height"].as_f64().unwrap() - 127.0 / 25.4).abs() < 1e-9);
assert!(papers.iter().any(|p| p["name"] == "postcard") && papers.iter().any(|p| p["name"] == "6cut"));
assert_eq!(g["gridCells"].as_array().unwrap().len(), 1);
let g = run(
@@ -213,6 +219,28 @@ fn export_pdf_prints_the_current_settings() {
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
}
/// The Japanese photo and postcard papers reach the PDF page at their metric sizes: はがき is
/// 100 × 148 mm (283.46 × 419.53 pt) and L is 89 × 127 mm (252.28 × 359.99 pt). 254 ppi is exactly
/// ten pixels per millimetre, so the page pixels are whole millimetres and the MediaBox is exact.
#[test]
fn export_pdf_uses_the_japanese_paper_sizes() {
let mut s = Session::with_demo();
let id = s.catalog.photos().next().map(|p| p.id.0).unwrap();
for (paper, w_pt, h_pt) in [("postcard", 283.46, 419.53), ("はがき", 283.46, 419.53), ("l", 252.28, 359.99)] {
run(&mut s, "print.set", json!({"layout": {"style": "singleImage", "page": {"paper": paper}}, "job": {"ppi": 254.0}}));
let path = temp_pdf(paper);
run(&mut s, "print.exportPdf", json!({"path": path, "ids": [id]}));
let bytes = std::fs::read(&path).unwrap();
let (w, h) = media_box(&bytes);
assert!((w - w_pt).abs() < 0.1 && (h - h_pt).abs() < 0.1, "{paper}: {w} x {h} pt, wanted {w_pt} x {h_pt}");
}
// The customary names are accepted and the page size follows them; an unknown name is refused.
let g = run(&mut s, "print.set", json!({"layout": {"page": {"paper": "はがき"}}}));
assert!((g["pageWidth"].as_f64().unwrap() - 100.0 / 25.4).abs() < 1e-9);
assert!((g["pageHeight"].as_f64().unwrap() - 148.0 / 25.4).abs() < 1e-9);
fails(&mut s, "print.set", json!({"layout": {"page": {"paper": "poster"}}}));
}
#[test]
fn export_pdf_with_a_printer_profile() {
let mut s = Session::with_demo();
+9 -1
View File
@@ -77,16 +77,23 @@ fn status_and_model_commands_without_the_model() {
#[test]
fn a_failing_download_ends_with_an_error_and_never_blocks() {
use std::io::{BufRead, BufReader, Write};
// a local mirror that has nothing
// a local mirror that has nothing; its first answer waits until the test has made its second
// request, so the download is still running then however the threads are scheduled (a 404 at
// once could end it before, and the second request would rightly start a new one)
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/sam3", l.local_addr().unwrap());
let (release, released) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let mut first = Some(released);
for mut c in l.incoming().flatten() {
let mut r = BufReader::new(c.try_clone().unwrap());
let mut line = String::new();
while r.read_line(&mut line).unwrap_or(0) > 2 {
line.clear();
}
if let Some(released) = first.take() {
let _ = released.recv_timeout(Duration::from_secs(10));
}
let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n");
}
});
@@ -103,6 +110,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
// a second request while it runs starts nothing
let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
assert!(again["started"] == false || again["downloading"] == false);
let _ = release.send(());
let t = Instant::now();
while s.segmenter.download_status().running {
assert!(t.elapsed() < Duration::from_secs(30), "the download never ended");
+99 -7
View File
@@ -1,11 +1,12 @@
//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter
//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines
//! SemiBold (bundled) first, then the craft-fonts CJK and Thai faces as fallbacks ([`faces`]); glyph outlines
//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out
//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor
//! it before drawing; drawing hands out per-pixel coverage, and the caller blends.
//!
//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers
//! draw as Inter's missing-glyph box: wrong-looking, never a crash.
//! Horizontal text has no contextual shaping or bidi reordering; Thai combining marks use
//! per-character metrics rather than shaped positioning. Arabic faces are not offered. Characters
//! no face covers draw as Inter's missing-glyph box.
use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point};
@@ -16,13 +17,16 @@ pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBo
pub const MAX_CHARS: usize = 4000;
/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
/// serif look; then any other CJK face), then Thai faces, preferring Regular. Drawn text has no
/// UI language, so every CJK script is offered. Other script faces stay out until their layout is
/// supported. Without craft-fonts that is Inter alone, with missing-glyph boxes for unsupported text.
pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
let thai = crate::fonts::ui_fallback(craft, "Latn", "Regular")
.into_iter()
.filter(|f| f.covers("Thai") && !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s)));
std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(thai).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
}
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
@@ -223,3 +227,91 @@ impl TextLayout {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fonts::{CRAFT_FONTS, CraftFont};
fn assert_script_glyphs(script: &str, text: &str) {
if !CRAFT_FONTS.iter().any(|font| font.covers(script)) {
lightcraft_testkit::skip!(Fonts, "built without the {script} face from craft-fonts");
return;
}
let layout = TextLayout::new(text, 40.0, false, CRAFT_FONTS).unwrap();
assert_eq!(layout.glyphs.len(), text.chars().count());
for (ch, (face, glyph, _)) in text.chars().zip(&layout.glyphs) {
assert_ne!(glyph.id.0, 0, "missing {script} glyph for {ch}");
let font = &layout.fonts[*face];
assert!(CRAFT_FONTS.iter().any(|entry| entry.covers(script) && entry.bytes == font.font_data()));
}
let mut covered = 0;
layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0));
assert!(covered > 0, "{script} outlines are rasterized");
}
#[test]
fn bundled_arabic_stays_out_of_export_fallback() {
if !CRAFT_FONTS.iter().any(|font| font.covers("Arab")) {
lightcraft_testkit::skip!(Fonts, "built without the Arabic face from craft-fonts");
return;
}
// UI names still use their existing Arabic face. Export needs joining and bidi first.
assert!(crate::fonts::ui_fallback(CRAFT_FONTS, "Latn", "Regular").iter().any(|font| font.covers("Arab")));
for vertical in [false, true] {
let layout = TextLayout::new("مرحبا", 40.0, vertical, CRAFT_FONTS).unwrap();
assert!(layout.glyphs.iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0));
}
}
#[test]
fn bundled_thai_uses_its_glyphs() {
assert_script_glyphs("Thai", "สวัสดีครับ");
}
#[test]
fn absent_fonts_keep_inter_and_missing_glyph_boxes() {
assert_eq!(faces(&[]).len(), 1);
let layout = TextLayout::new("Aกم", 40.0, false, &[]).unwrap();
assert_ne!(layout.glyphs[0].1.id.0, 0);
assert!(layout.glyphs[1..].iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0));
let mut covered = 0;
layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0));
assert!(covered > 0);
}
#[test]
fn latin_and_cjk_rendering_keeps_the_existing_preference() {
let mut cjk: Vec<_> = crate::fonts::cjk(CRAFT_FONTS).collect();
if cjk.is_empty() {
lightcraft_testkit::skip!(Fonts, "built without CJK faces from craft-fonts");
return;
}
cjk.sort_by_key(|font| !font.is_mincho());
let previous: &'static [CraftFont] = Box::leak(
cjk.into_iter()
.map(|font| CraftFont { family: font.family, style: font.style, scripts: font.scripts, bytes: font.bytes })
.collect::<Vec<_>>()
.into_boxed_slice(),
);
let actual = faces(CRAFT_FONTS);
assert!(actual.len() > previous.len());
assert!(actual[0].font_data() == TEXT_FONT, "Inter stays first");
for (font, expected) in actual.iter().skip(1).zip(previous) {
assert!(font.font_data() == expected.bytes, "existing CJK face order: {} {}", expected.family, expected.style);
}
for vertical in [false, true] {
for text in ["LightCraft AV 123", "日本語、。你好世界한국어"] {
let before = TextLayout::new(text, 40.0, vertical, previous).unwrap();
let after = TextLayout::new(text, 40.0, vertical, CRAFT_FONTS).unwrap();
assert_eq!((before.width, before.height), (after.width, after.height));
let pixels = |layout: &TextLayout| {
let mut pixels = Vec::new();
layout.draw(0.0, 0.0, |x, y, coverage| pixels.push((x, y, coverage)));
pixels
};
assert!(pixels(&before) == pixels(&after), "{text}, vertical={vertical}");
}
}
}
}
+8 -6
View File
@@ -450,11 +450,12 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
}
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on
/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it,
/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart.
pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do
/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and
/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes
/// its slider-drag drafts by it (issue #330), so the two can't drift apart.
pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange
}
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
@@ -473,7 +474,8 @@ pub fn try_render(
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) {
let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some());
if !enabled() || !handles(req.depth, device_proof, req.overlay) {
return Err(Fallback::NotTried);
}
let s = &*lightcraft_pipeline::settings_for(s, req);
+15
View File
@@ -73,6 +73,13 @@ const FIELDS: &[(&str, usize)] = &[
("OUT_Y", 3),
("OUT_TRC", 1),
("OUT_GAMMA", 1),
// soft proofing (matrix proofs; `ProofParams`): working → proof RGB, its luma, proof → output, warnings
("PROOF", 1),
("PROOF_M", 9),
("PROOF_Y", 3),
("PROOF_OUT", 9),
("PROOF_DEST_WARN", 1),
("PROOF_DISP_WARN", 1),
// first row of a band dispatch (the kernel runs over rows Y0.., see `render`)
("Y0", 1),
];
@@ -233,6 +240,14 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
let (trc, gamma) = fp.out_trc.code();
p.u("OUT_TRC", trc);
p.f("OUT_GAMMA", gamma);
if let Some(pp) = fp.proof.as_ref().filter(|pp| pp.table.is_none()) {
p.b("PROOF", true);
p.fs("PROOF_M", pp.to_proof.as_flattened());
p.fs("PROOF_Y", &pp.luma);
p.fs("PROOF_OUT", pp.proof_to_out.as_flattened());
p.b("PROOF_DEST_WARN", pp.dest_warning);
p.b("PROOF_DISP_WARN", pp.display_warning);
}
if let Some((amt, cell, rough, seed)) = fp.grain {
p.b("GRAIN", true);
p.f("GRAIN_AMT", amt);
+3 -1
View File
@@ -669,8 +669,10 @@ pub fn render(
// 5. per-pixel stage
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
// soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`)
fp.proof = req.proof.map(|pr| pr.params(req.space));
if let Some(d) = &req.display {
fp.for_display(d, None);
fp.for_display(d, req.proof);
}
let present = Present {
clarity: prep.clarity.is_some(),
+67 -18
View File
@@ -252,6 +252,46 @@ fn out_encode(v: f32) -> f32 {
return 1.099 * pow(l, 0.45) - 0.099;
}
// The 3×3 matrix stored row by row from field `o`.
fn mat_at(o: u32) -> array<vec3<f32>, 3> {
return array<vec3<f32>, 3>(
vec3<f32>(pf(o), pf(o + 1u), pf(o + 2u)),
vec3<f32>(pf(o + 3u), pf(o + 4u), pf(o + 5u)),
vec3<f32>(pf(o + 6u), pf(o + 7u), pf(o + 8u)),
);
}
// `finish::gamut_map` (SDR): the chroma scale that brings `r` into 0..1 towards its luminance (1: in gamut) ...
fn gamut_scale(r: vec3<f32>, luma: vec3<f32>) -> f32 {
let yy = clamp(dot(luma, r), 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
if (cc < 0.0) {
tg = min(tg, yy / max(yy - cc, 1e-9));
} else if (cc > 1.0) {
tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9));
}
}
return tg;
}
// ... and `r` scaled by it.
fn gamut_apply(r: vec3<f32>, luma: vec3<f32>, tg: f32) -> vec3<f32> {
if (tg < 1.0) {
let yy = clamp(dot(luma, r), 0.0, 1.0);
return yy + (r - yy) * tg;
}
return r;
}
// `finish::out_of_gamut`: `r` needed desaturating by `t` to fit and isn't just a neutral beyond white or below black.
fn out_of_gamut(r: vec3<f32>, t: f32) -> bool {
let lo = min(r.x, min(r.y, r.z));
let hi = max(r.x, max(r.y, r.z));
return t < 0.995 && (hi - lo) > 0.02 * max(abs(hi), 1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0));
}
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let w = pu(F_W);
@@ -324,8 +364,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// --- dehaze (scene linear)
let dz = pf(F_DEHAZE) + lt[10];
if (dz != 0.0 && pu(F_HAS_DARK) != 0u) {
let d = clamp(dark[i] / pf(F_AIR_PRE), 0.0, 1.0);
// the dark channel is a blur, not a patch minimum: never attribute more veil than the
// pixel's own minimum channel, or dark pixels next to bright ones crush to black (#2504)
let air = pf(F_AIR);
let cmin = min(c.x, min(c.y, c.z));
let d = clamp(min(select(0.0, dark[i] / pf(F_AIR_PRE), pf(F_AIR_PRE) > 0.0), select(0.0, cmin / air, air > 0.0)), 0.0, 1.0);
if (dz > 0.0) {
let t = max(1.0 - 0.95 * min(dz, 1.0) * d, 0.12);
c = max((c - air * (1.0 - t)) / t, vec3<f32>(0.0));
@@ -467,26 +510,27 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
// --- gamut map to the output space (desaturate towards luminance until in range)
let om = array<vec3<f32>, 3>(
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
);
var r = mul3(om, d);
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
if (cc < 0.0) {
tg = min(tg, yy / max(yy - cc, 1e-9));
} else if (cc > 1.0) {
tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9));
// --- gamut map to the output space (desaturate towards luminance until in range);
// soft proofing maps into the proof space first and shows that in the output space
// (warn: 0 none, 1 destination gamut, 2 display gamut)
var warn = 0u;
var r: vec3<f32>;
if (pu(F_PROOF) != 0u) {
let q0 = mul3(mat_at(F_PROOF_M), d);
let tq = gamut_scale(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)));
if (pu(F_PROOF_DEST_WARN) != 0u && out_of_gamut(q0, tq)) {
warn = 1u;
}
r = mul3(mat_at(F_PROOF_OUT), gamut_apply(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)), tq));
} else {
r = mul3(mat_at(F_OUT_M), d);
}
if (tg < 1.0) {
r = yy + (r - yy) * tg;
let oy = vec3<f32>(pf(F_OUT_Y), pf(F_OUT_Y + 1u), pf(F_OUT_Y + 2u));
let tg = gamut_scale(r, oy);
if (pu(F_PROOF) != 0u && pu(F_PROOF_DISP_WARN) != 0u && warn == 0u && out_of_gamut(r, tg)) {
warn = 2u;
}
r = gamut_apply(r, oy, tg);
// --- encode, curves, grain
var e = vec3<f32>(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z));
@@ -512,6 +556,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e + k;
}
if (warn == 1u) {
e = vec3<f32>(1.0, 0.0, 0.0);
} else if (warn == 2u) {
e = vec3<f32>(0.0, 0.25, 1.0);
}
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
}
+64
View File
@@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings,
(mean, max)
}
/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a
/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding).
/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds.
fn check_warned(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) {
let cpu = render(src, info, s, req).image;
let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render");
let (mean, _, over) = diff(&cpu, &gpu.image);
let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count();
let share = flips as f64 / cpu.data.len() as f64;
eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0);
assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB");
}
type Edit = fn(&mut DevelopSettings);
fn typical(s: &mut DevelopSettings) {
@@ -877,3 +890,54 @@ fn windows_of_a_big_photo_share_one_uploaded_source() {
lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render");
assert_eq!(lightcraft_gpu::source_uploads() - start, 2);
}
#[test]
fn soft_proofs_match() {
if !gpu() {
return;
}
// Soft proofing against an output space (matrix proofs): into the proof gamut, back to the
// output space, with the destination and display gamut warnings painted alike on both renderers.
// Saturated edits put plenty of colours outside the smaller proof spaces.
use lightcraft_pipeline::{OutputSpace, Proof};
let src = scene(0, 900, 600);
let raw = SourceInfo { raw: true, ..Default::default() };
for space in [OutputSpace::Srgb, OutputSpace::AdobeRgb, OutputSpace::DisplayP3] {
for (dest_warning, display_warning) in [(false, false), (true, false), (false, true), (true, true)] {
for out in [OutputSpace::Srgb, OutputSpace::Rec2020] {
let mut s = DevelopSettings::default();
saturated(&mut s);
let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 });
let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) };
let name = format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}");
if dest_warning || display_warning {
check_warned(&name, &src, &raw, &s, &req);
} else {
check(&name, &src, &raw, &s, &req);
}
}
}
}
// the case is meaningful: proofing sRGB from Rec. 2020 output warns on some pixels, and the GPU paints the same ones
let mut s = DevelopSettings::default();
saturated(&mut s);
let req = RenderRequest {
space: OutputSpace::Rec2020,
proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 }),
..RenderRequest::fit(640, 640)
};
let warned = |i: &Rgba8| i.data.iter().filter(|p| p[1] == 0 && p[2] == 0 && p[0] > 200).count();
let (cpu, gpu) = (render(&src, &raw, &s, &req).image, lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu render").image);
assert!(warned(&cpu) > 100, "{} warned pixels", warned(&cpu));
let (a, b) = (warned(&cpu), warned(&gpu));
assert!(a.abs_diff(b) as f64 <= 1e-4 * cpu.data.len() as f64, "warned pixels: CPU {a}, GPU {b}");
// an output-device profile (a 3D table) stays on the CPU
let n = 2;
let lut = lightcraft_pipeline::ProofLut::new(n, vec![[0.5, 0.5, 0.5, 0.0]; n * n * n]).unwrap();
let id = lightcraft_pipeline::register_proof_profile(0x5eed_9f00, lut);
let req = RenderRequest {
proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: id }),
..RenderRequest::fit(64, 64)
};
assert!(lightcraft_gpu::render(&src, &raw, &DevelopSettings::default(), &req, None).is_none(), "declined: the CPU renders it");
}
+2 -16
View File
@@ -5,7 +5,6 @@
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::resample::{Filter, fit};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::features::{self, Features};
use crate::ransac::{self, Model};
@@ -129,23 +128,10 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped
/// image and a validity mask (false where `h(p)` falls outside `img`).
pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
if *h == Homography::IDENTITY {
return (img.clone(), vec![true; w * ht]);
return (img.clone(), vec![true; img.width * img.height]);
}
let mut out = Rgb32f::new(w, ht);
let mut valid = vec![false; w * ht];
out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| {
for x in 0..w {
let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let (sx, sy) = (p.x as f32, p.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
});
(out, valid)
crate::flow::warp(img, h, &crate::flow::Flow::default())
}
#[cfg(test)]
+239
View File
@@ -0,0 +1,239 @@
//! Local alignment: what one motion model for the whole frame leaves.
//!
//! A homography is exact only for a camera that turned about its lens. Handheld, the camera also
//! moves, and things at different distances shift differently (parallax): on a real bracket (Sony,
//! 33 MP, gravel a few metres away and a building a kilometre off) the fit, dominated by the
//! foreground's texture, left the building 2.3 px off, and the merge doubled its inscription. Lens
//! distortion and rolling shutter leave local shifts too.
//!
//! After the global fit ([`crate::align`], [`crate::refine`]) the frame is compared with the
//! reference tile by tile:
//!
//! 1. **Measure.** On a grid of about [`GRID`] tiles along the long edge, the reference's patch at
//! the tile's centre is found in the frame, through the global fit, within [`RADIUS`] pixels
//! ([`crate::refine::match_patch`]: masked correlation, sub-pixel peak, ambiguous and textureless
//! patches dropped).
//! 2. **Clean.** A shift more than [`OUTLIER`] px from the median of its measured neighbours is
//! dropped (a person walking, a mismatch), as is one with no measured neighbour to check it by. Tiles without a measurement take the mean of their
//! measured neighbours, spreading outwards until every tile has one.
//! 3. **Smooth** with two 3 × 3 box passes, so the field bends gently.
//! 4. **Warp.** Each output pixel `p` samples the frame at `h(p + flow(p))` (the flow interpolated
//! bilinearly between tile centres): one Lanczos-3 resampling for both corrections.
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::refine::{self, PATCH};
/// Tiles along the long edge.
pub const GRID: usize = 96;
/// Largest local shift measured (pixels); farther ones are filled from neighbours.
pub const RADIUS: usize = 5;
/// A tile this far (pixels) from the median of its neighbours is an outlier.
pub const OUTLIER: f32 = 0.75;
/// A smooth field of local shifts in reference pixels: one value per tile, tile `(i, j)`'s at
/// `((i + ½)·step, (j + ½)·step)`, so pixel centres of `dx` / `dy` line up with the tiles'.
#[derive(Clone, Debug, PartialEq)]
pub struct Flow {
pub step: usize,
pub dx: Plane,
pub dy: Plane,
/// Tiles that were measured (before filling).
pub measured: usize,
}
impl Default for Flow {
/// No shift anywhere.
fn default() -> Self {
Flow { step: 0, dx: Plane::new(0, 0), dy: Plane::new(0, 0), measured: 0 }
}
}
impl Flow {
/// The shift at reference coordinates (pixel centres at +0.5), bilinear between tile centres;
/// zero for an empty flow.
pub fn at(&self, x: f64, y: f64) -> (f64, f64) {
let (fx, fy) = ((x / self.step as f64) as f32, (y / self.step as f64) as f32);
if self.dx.data.is_empty() || !fx.is_finite() || !fy.is_finite() {
return (0.0, 0.0);
}
(crate::features::bilinear(&self.dx, fx, fy) as f64, crate::features::bilinear(&self.dy, fx, fy) as f64)
}
/// The largest shift in the field (pixels).
pub fn max(&self) -> f32 {
self.dx.data.iter().zip(&self.dy.data).map(|(x, y)| x.hypot(*y)).fold(0.0, f32::max)
}
}
/// The local shifts between `reference` and `frame` (both from [`refine::luma`]) once `h` (reference
/// → frame pixels) is applied, within `radius` pixels.
pub fn estimate(reference: &Plane, frame: &Plane, h: &Homography, radius: usize) -> Flow {
let (w, ht) = (reference.width, reference.height);
// an even step, so a patch centred on a tile has a whole-pixel corner
let step = ((w.max(ht) / GRID).max(PATCH) + 1) & !1;
let (gw, gh) = (w / step, ht / step);
if gw == 0 || gh == 0 || frame.data.is_empty() {
return Flow::default();
}
let r = radius.max(1);
let raw: Vec<Option<[f32; 2]>> = (0..gw * gh)
.into_par_iter()
.map(|t| {
let (i, j) = (t % gw, t / gw);
let (sx, sy) = (i * step + (step - PATCH) / 2, j * step + (step - PATCH) / 2);
if sx < r + 1 || sy < r + 1 || sx + PATCH + r + 1 > w || sy + PATCH + r + 1 > ht {
return None;
}
refine::match_patch(reference, (sx, sy), frame, h, r).map(|m| [m.offset.x as f32, m.offset.y as f32])
})
.collect();
let measured = raw.iter().filter(|v| v.is_some()).count();
let at = |d: &[Option<[f32; 2]>], k: usize| d.get(k).copied().flatten();
// 2. outliers against the median of their measured neighbours
let mut d: Vec<Option<[f32; 2]>> = (0..gw * gh)
.map(|t| {
let v = at(&raw, t)?;
let n = neighbours(|k| at(&raw, k), gw, gh, t);
let med = [median(n.iter().map(|v| v[0]).collect()), median(n.iter().map(|v| v[1]).collect())];
// a tile with no measured neighbour can't be checked, and the fill would spread it: dropped
(!n.is_empty() && (v[0] - med[0]).hypot(v[1] - med[1]) <= OUTLIER).then_some(v)
})
.collect();
// fill outwards from measured tiles
for _ in 0..gw + gh {
if d.iter().all(Option::is_some) || d.iter().all(Option::is_none) {
break;
}
let prev = d.clone();
for (t, slot) in d.iter_mut().enumerate().filter(|(_, s)| s.is_none()) {
*slot = mean(&neighbours(|k| at(&prev, k), gw, gh, t));
}
}
let mut f: Vec<[f32; 2]> = d.into_iter().map(|v| v.unwrap_or([0.0; 2])).collect();
// 3. smooth (each tile with its neighbours)
for _ in 0..2 {
let prev = f.clone();
for (t, v) in f.iter_mut().enumerate() {
let mut n = neighbours(|k| prev.get(k).copied(), gw, gh, t);
n.push(*v);
*v = mean(&n).unwrap_or(*v);
}
}
let plane = |c: usize| Plane::from_fn(gw, gh, |i, j| f.get(j * gw + i).map_or(0.0, |v| v[c]));
Flow { step, dx: plane(0), dy: plane(1), measured }
}
/// The values `get` has among the 8 neighbours of tile `t`.
fn neighbours(get: impl Fn(usize) -> Option<[f32; 2]>, gw: usize, gh: usize, t: usize) -> Vec<[f32; 2]> {
let (i, j) = (t % gw, t / gw);
let mut out = Vec::with_capacity(8);
for b in j.saturating_sub(1)..=(j + 1).min(gh - 1) {
for a in i.saturating_sub(1)..=(i + 1).min(gw - 1) {
if (a, b) != (i, j)
&& let Some(v) = get(b * gw + a)
{
out.push(v);
}
}
}
out
}
fn mean(n: &[[f32; 2]]) -> Option<[f32; 2]> {
let k = n.len() as f32;
(!n.is_empty()).then(|| [n.iter().map(|v| v[0]).sum::<f32>() / k, n.iter().map(|v| v[1]).sum::<f32>() / k])
}
fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.total_cmp(b));
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// `img` resampled into the reference's geometry through `h` after the local shifts:
/// `out(p) = img(h(p + flow(p)))` (Lanczos-3), and where that falls inside `img`.
pub fn warp(img: &Rgb32f, h: &Homography, flow: &Flow) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
let mut out = Rgb32f::new(w, ht);
let mut valid = vec![false; w * ht];
if w == 0 || ht == 0 {
return (out, valid);
}
out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| {
for x in 0..w {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let (dx, dy) = flow.at(px, py);
let q = h.apply(Point::new(px + dx, py + dy));
let (sx, sy) = (q.x as f32, q.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
});
(out, valid)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::refine::tests::tex;
/// Parallax: the top of the frame (far) shifted 2.2 px against the bottom (near), with a
/// smooth band between. The flow finds both, and the warp lines the frame up everywhere.
#[test]
fn finds_and_undoes_a_local_shift() {
let (w, h) = (1200usize, 800usize);
let shift = |y: f64| {
let t = ((y - 300.0) / 200.0).clamp(0.0, 1.0);
let s = t * t * (3.0 - 2.0 * t);
((1.0 - s) * 2.2, (1.0 - s) * -1.1)
};
let reference = Plane::from_fn(w, h, |x, y| tex(x as f64 + 0.5, y as f64 + 0.5));
// the frame's pixel q shows the scene at q + shift(q.y)
let frame = Rgb32f::from_fn(w, h, |x, y| {
let (sx, sy) = shift(y as f64 + 0.5);
[tex(x as f64 + 0.5 + sx, y as f64 + 0.5 + sy); 3]
});
let fl = refine::luma(&frame, 1.0, 10.0);
let flow = estimate(&reference, &fl, &Homography::IDENTITY, RADIUS);
let tiles = flow.dx.width * flow.dx.height;
assert!(flow.measured > tiles / 2, "{} of {tiles}", flow.measured);
// reference p matches frame p + d with d = −shift
let (tx, ty) = flow.at(600.0, 100.0);
assert!((tx + 2.2).abs() < 0.15 && (ty - 1.1).abs() < 0.15, "top {tx} {ty}");
let (bx, by) = flow.at(600.0, 700.0);
assert!(bx.abs() < 0.15 && by.abs() < 0.15, "bottom {bx} {by}");
let (out, valid) = warp(&frame, &Homography::IDENTITY, &flow);
let error = |img: &Rgb32f| {
let (mut e, mut n) = (0f64, 0usize);
for y in (40..h - 40).step_by(3) {
for x in (40..w - 40).step_by(3) {
if valid[y * w + x] {
e += (img.get(x, y)[0] - reference.get(x, y)).abs() as f64;
n += 1;
}
}
}
e / n as f64
};
let (before, after) = (error(&frame), error(&out));
assert!(after < before / 6.0, "mean error {before} unwarped, {after} warped");
}
#[test]
fn empty_and_flat_inputs_give_no_shift() {
let flat = Plane::from_fn(400, 300, |_, _| 0.5);
let f = estimate(&flat, &flat, &Homography::IDENTITY, RADIUS);
assert_eq!(f.measured, 0);
assert_eq!(f.max(), 0.0);
assert_eq!(estimate(&Plane::new(0, 0), &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default());
assert_eq!(estimate(&flat, &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default());
assert_eq!(Flow::default().at(5.0, f64::NAN), (0.0, 0.0));
assert_eq!(f.at(f64::INFINITY, 3.0), (0.0, 0.0));
let (img, v) = warp(&Rgb32f::new(0, 0), &Homography::IDENTITY, &Flow::default());
assert!(img.data.is_empty() && v.is_empty());
}
}
+2
View File
@@ -15,12 +15,14 @@
pub mod align;
pub mod features;
pub mod flow;
pub mod frame;
pub mod hdr;
pub mod linalg;
pub mod output;
pub mod pano;
pub mod ransac;
pub mod refine;
pub mod synth;
pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with};
+359
View File
@@ -0,0 +1,359 @@
//! Full-resolution refinement of an alignment found on small versions of the frames.
//!
//! [`crate::align`] registers frames on versions of at most [`crate::align::WORK_EDGE`] pixels.
//! For a 24–60 MP photo that is 5–8× smaller, and a tenth of a work pixel off is most of a full
//! pixel off: merged frames that far apart blur the result. Here the motion is fitted again at
//! full size:
//!
//! 1. **Sites.** The reference is cut into a grid of cells (≈ [`CELLS`] along the long edge); in
//! each, the [`PATCH`]-pixel square with the most two-dimensional texture (the smaller
//! eigenvalue of its gradient structure tensor, so a straight edge, which only fixes one
//! direction, doesn't count) is a site.
//! 2. **Matching.** Each site's patch is compared with the frame resampled through the current
//! alignment, by normalised cross-correlation (immune to the frames' different brightness and
//! contrast) over integer offsets within a search radius, counting only pixels usable in both
//! (clipped ones aren't); the peak is refined to a fraction of a pixel by a parabola through its
//! neighbours on each axis. Patches less than half usable, with too little texture, a weak peak, a peak on the search border or a second peak almost
//! as strong (repeating texture) are dropped.
//! 3. **Fit.** The matches are fitted with RANSAC at [`THRESH`] pixels, choosing the simplest of
//! translation / similarity / homography that explains (nearly) as many as the most general
//! one, as [`crate::align::align_pair`] does. A second, narrower pass starts from that fit.
//!
//! When too few patches match, or the fit isn't a plausible handheld motion, the alignment found
//! on the small versions stays: refining never makes an alignment worse than not refining.
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::align::{Alignment, Models, choose_model, luma as lum};
use crate::ransac::Model;
/// Side of a matched patch (pixels).
pub const PATCH: usize = 48;
/// Cells along the long edge (sites are picked one per cell).
pub const CELLS: usize = 24;
/// RANSAC inlier threshold at full resolution (pixels).
pub const THRESH: f64 = 0.75;
/// Weakest correlation peak a match may have.
const MIN_SCORE: f32 = 0.8;
/// The best peak away from the best one must be at least this much weaker.
const UNIQUE: f32 = 0.03;
/// Least standard deviation (of the gamma-encoded luminance) a patch needs to be matched.
const MIN_STD: f32 = 0.01;
/// Fewest matches a fit is made from.
const MIN_MATCHES: usize = 12;
/// Share of the matches the fit must explain.
const MIN_SHARE: f64 = 0.4;
/// A frame's luminance for matching: `(luminance · gain)^(1/2.2)` at full size, NaN where the
/// frame is clipped (any channel at or above 95 % of `clip`) or `gain` takes it past 1, so such
/// pixels never take part. `gain` brings frames of a bracket to the same brightness.
pub fn luma(img: &Rgb32f, gain: f32, clip: f32) -> Plane {
let g = if gain.is_finite() && gain > 0.0 { gain } else { 1.0 };
img.map(|c| {
if c[0].max(c[1]).max(c[2]) >= 0.95 * clip {
return f32::NAN;
}
let y = lum(c) * g;
if y.is_finite() && y < 1.0 { y.max(0.0).powf(1.0 / 2.2) } else { f32::NAN }
})
}
/// The search radius (full-resolution pixels) for an alignment found at `scale` (work pixels per
/// full pixel): about two work pixels.
pub fn radius_for(scale: f64) -> usize {
if scale.is_finite() && scale > 0.0 { ((2.0 / scale).ceil() as usize + 1).clamp(3, 16) } else { 3 }
}
/// A patch of the reference found in a frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Match {
/// Centre of the patch in the reference (pixel centres at +0.5).
pub reference: Point,
/// Where it is in the frame.
pub frame: Point,
/// The offset from where the alignment put it, in reference pixels.
pub offset: Point,
/// Correlation at the peak (−1..1).
pub score: f32,
}
/// Top-left corners of the patches to match: one per cell, where the reference has the most
/// two-dimensional texture, `margin` pixels clear of the edges.
pub fn sites(reference: &Plane, margin: usize) -> Vec<(usize, usize)> {
let (w, h) = (reference.width, reference.height);
let need = PATCH + 2 * margin;
if w < need || h < need {
return Vec::new();
}
let long = w.max(h);
let cell = long.div_ceil(CELLS).max(PATCH);
let (nx, ny) = ((w - 2 * margin) / cell, (h - 2 * margin) / cell);
let (nx, ny) = (nx.max(1), ny.max(1));
let (cw, chh) = ((w - 2 * margin) / nx, (h - 2 * margin) / ny);
(0..nx * ny)
.into_par_iter()
.filter_map(|c| {
let (cx, cy) = (margin + (c % nx) * cw, margin + (c / nx) * chh);
let span_x = cw.saturating_sub(PATCH);
let span_y = chh.saturating_sub(PATCH);
let mut best: Option<((usize, usize), f32)> = None;
for j in 0..3 {
for i in 0..3 {
let (x, y) = (cx + span_x * i / 2, cy + span_y * j / 2);
if x + PATCH > w - margin || y + PATCH > h - margin {
continue;
}
if let Some(t) = texture(reference, x, y)
&& best.is_none_or(|(_, b)| t > b)
{
best = Some(((x, y), t));
}
}
}
best.filter(|(_, t)| *t > 1e-6).map(|(p, _)| p)
})
.collect()
}
/// The smaller eigenvalue of the patch's mean gradient structure tensor, over its usable pixels;
/// `None` when fewer than half of them are.
fn texture(p: &Plane, x0: usize, y0: usize) -> Option<f32> {
let w = p.width;
let at = |x: usize, y: usize| p.data.get(y * w + x).copied().unwrap_or(f32::NAN);
let (mut sxx, mut syy, mut sxy, mut n) = (0f64, 0f64, 0f64, 0usize);
for y in y0 + 1..y0 + PATCH - 1 {
for x in x0 + 1..x0 + PATCH - 1 {
let gx = (at(x + 1, y) - at(x - 1, y)) as f64;
let gy = (at(x, y + 1) - at(x, y - 1)) as f64;
if gx.is_finite() && gy.is_finite() {
sxx += gx * gx;
syy += gy * gy;
sxy += gx * gy;
n += 1;
}
}
}
if n * 2 < (PATCH - 2) * (PATCH - 2) {
return None;
}
let n = n as f64;
let (a, b, c) = (sxx / n, syy / n, sxy / n);
let tr = a + b;
let det = a * b - c * c;
Some((tr / 2.0 - ((tr * tr / 4.0 - det).max(0.0)).sqrt()) as f32)
}
/// Find the reference patch at `site` (top-left) in `frame`, which `h` maps reference pixels into,
/// searching offsets up to `radius` pixels.
pub fn match_patch(reference: &Plane, site: (usize, usize), frame: &Plane, h: &Homography, radius: usize) -> Option<Match> {
let (sx, sy) = site;
let (rw, rh) = (reference.width, reference.height);
if sx + PATCH > rw || sy + PATCH > rh || frame.width == 0 || frame.height == 0 {
return None;
}
let r = radius.max(1);
let s = PATCH + 2 * r;
let mut pr: Vec<f32> = Vec::with_capacity(PATCH * PATCH);
for y in sy..sy + PATCH {
for x in sx..sx + PATCH {
pr.push(reference.data.get(y * rw + x).copied().unwrap_or(f32::NAN));
}
}
// the frame around the patch, through `h`; NaN where it can't be used
let (fw, fh) = (frame.width as f64, frame.height as f64);
let mut win = vec![f32::NAN; s * s];
for j in 0..s {
for i in 0..s {
let q = h.apply(Point::new((sx + i) as f64 - r as f64 + 0.5, (sy + j) as f64 - r as f64 + 0.5));
if !(q.x >= 0.5 && q.y >= 0.5 && q.x <= fw - 0.5 && q.y <= fh - 0.5) {
return None;
}
win[j * s + i] = crate::features::bilinear(frame, q.x as f32, q.y as f32);
}
}
let d = 2 * r + 1;
// offsets that can't be scored (too few usable pixels) stay at −∞
let mut score = vec![f32::NEG_INFINITY; d * d];
for dy in 0..d {
for dx in 0..d {
if let Some(c) = correlation(&pr, |u, v| win.get((v + dy) * s + dx + u).copied().unwrap_or(f32::NAN)) {
score[dy * d + dx] = c;
}
}
}
let (bi, &best) = score.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))?;
let (bx, by) = (bi % d, bi / d);
if best < MIN_SCORE || bx == 0 || by == 0 || bx == d - 1 || by == d - 1 {
return None;
}
// a second peak nearly as strong: repeating texture, the match is ambiguous
let second =
score.iter().enumerate().filter(|(k, _)| (k % d).abs_diff(bx) > 1 || (k / d).abs_diff(by) > 1).map(|(_, v)| *v).fold(-1f32, f32::max);
if second > best - UNIQUE {
return None;
}
let at = |x: usize, y: usize| score[y * d + x];
// the sub-pixel parabola needs the peak's four neighbours: one that couldn't be scored would pull it away
if [at(bx - 1, by), at(bx + 1, by), at(bx, by - 1), at(bx, by + 1)].iter().any(|v| !v.is_finite()) {
return None;
}
let sub = |m: f32, c: f32, p: f32| {
let den = m - 2.0 * c + p;
if den < -1e-9 { (0.5 * (m - p) / den).clamp(-0.5, 0.5) } else { 0.0 }
};
let ox = bx as f64 - r as f64 + sub(at(bx - 1, by), best, at(bx + 1, by)) as f64;
let oy = by as f64 - r as f64 + sub(at(bx, by - 1), best, at(bx, by + 1)) as f64;
let c = Point::new(sx as f64 + PATCH as f64 / 2.0, sy as f64 + PATCH as f64 / 2.0);
Some(Match { reference: c, frame: h.apply(Point::new(c.x + ox, c.y + oy)), offset: Point::new(ox, oy), score: best })
}
/// Correlation of the reference patch `a` (row-major, [`PATCH`] wide) with `b(u, v)` over the
/// pixels usable in both; `None` with fewer than half of the patch usable, or too little texture in
/// either.
fn correlation(a: &[f32], b: impl Fn(usize, usize) -> f32) -> Option<f32> {
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0usize, 0f64, 0f64, 0f64, 0f64, 0f64);
for v in 0..PATCH {
for u in 0..PATCH {
let (x, y) = (a.get(v * PATCH + u).copied().unwrap_or(f32::NAN), b(u, v));
if x.is_finite() && y.is_finite() {
let (x, y) = (x as f64, y as f64);
n += 1;
sa += x;
sb += y;
saa += x * x;
sbb += y * y;
sab += x * y;
}
}
}
if n * 2 < PATCH * PATCH {
return None;
}
let nf = n as f64;
let (ma, mb) = (sa / nf, sb / nf);
let (va, vb) = (saa / nf - ma * ma, sbb / nf - mb * mb);
let min = (MIN_STD as f64).powi(2);
if !(va >= min && vb >= min * 0.25) {
return None;
}
let c = (sab / nf - ma * mb) / (va * vb).sqrt();
c.is_finite().then_some(c as f32)
}
/// Translation, similarity and homography: what a handheld frame may need.
pub const ALL_MODELS: [(Model, &str, usize); 3] =
[(Model::Translation, "translation", 6), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 12)];
/// Search radius (pixels) for a frame with no alignment to start from (the small versions found
/// none: a bright frame mostly clipped, too little texture left for features).
pub const RECOVER_RADIUS: usize = 16;
/// Fit `models` (reference → frame, full resolution) to patches of `reference` matched in `frame`
/// around `start` within `radius` pixels, then again within 2 px of that fit; both planes from
/// [`luma`]. `start` is an alignment found on small versions (`radius` from [`radius_for`]), or the
/// identity with [`RECOVER_RADIUS`]. `None` when too few patches match or no fit is a plausible
/// handheld motion: the caller keeps what it had.
pub fn fit(reference: &Plane, frame: &Plane, start: &Homography, radius: usize, seed: u64, models: &Models) -> Option<Alignment> {
let (w, ht) = (reference.width as f64, reference.height as f64);
let r0 = radius.max(2);
// the sites for the widest search fit the narrower one too
let s = sites(reference, r0 + 2);
let mut cur: Option<Alignment> = None;
for r in [r0, 2] {
let h = cur.as_ref().map_or(*start, |a| a.h);
let m: Vec<Match> = s.par_iter().filter_map(|&p| match_patch(reference, p, frame, &h, r)).collect();
if m.len() < MIN_MATCHES {
break;
}
let pa: Vec<Point> = m.iter().map(|m| m.reference).collect();
let pb: Vec<Point> = m.iter().map(|m| m.frame).collect();
let Some((name, f)) = choose_model(models, &pa, &pb, THRESH, MIN_SHARE, (w, ht), seed) else { break };
cur = Some(Alignment { h: f.h, model: name, inliers: f.inliers.len(), rms: f.rms });
}
cur
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A smooth random texture, sampled anywhere.
pub(crate) fn tex(x: f64, y: f64) -> f32 {
let mut v = 0.45;
for k in 0..9 {
let a = k as f64 * 2.399;
let f = 0.02 + 0.025 * k as f64;
v += 0.05 * ((x * a.cos() + y * a.sin()) * f * std::f64::consts::TAU + k as f64).sin();
}
v as f32
}
fn plane(w: usize, h: usize, map: &Homography) -> Plane {
Plane::from_fn(w, h, |x, y| {
let p = map.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
tex(p.x, p.y)
})
}
fn corner_error(a: &Homography, b: &Homography, w: f64, h: f64) -> f64 {
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| a.apply(Point::new(x, y)).dist(b.apply(Point::new(x, y)))).fold(0.0, f64::max)
}
/// A frame rotated 0.2° and moved 1.37 px, given an alignment 2 px off: refined to a tenth of
/// a pixel at the corners.
#[test]
fn refines_a_rough_alignment_to_a_fraction_of_a_pixel() {
let (w, h) = (900usize, 600usize);
let reference = plane(w, h, &Homography::IDENTITY);
// the frame's pixel q shows the scene at s(q); reference → frame is s⁻¹
let (c, sn) = (0.2f64.to_radians().cos(), 0.2f64.to_radians().sin());
let s = Homography([c, -sn, 1.37, sn, c, -0.83, 0.0, 0.0, 1.0]);
let truth = s.inverse().unwrap();
let frame = plane(w, h, &s);
let rough = Homography([truth.0[0], truth.0[1], truth.0[2] + 1.6, truth.0[3], truth.0[4], truth.0[5] - 1.2, 0.0, 0.0, 1.0]);
assert!(corner_error(&rough, &truth, w as f64, h as f64) > 1.5);
let r = fit(&reference, &frame, &rough, 4, 1, &ALL_MODELS).unwrap();
let e = corner_error(&r.h, &truth, w as f64, h as f64);
assert!(e < 0.1, "corner error {e} px, model {}", r.model);
assert!(r.inliers >= MIN_MATCHES);
}
/// A frame the small versions couldn't align, 5 px and 0.1° off: found from nothing.
#[test]
fn recovers_a_frame_with_no_alignment() {
let (w, h) = (900usize, 600usize);
let reference = plane(w, h, &Homography::IDENTITY);
let (c, sn) = (0.1f64.to_radians().cos(), 0.1f64.to_radians().sin());
let s = Homography([c, -sn, -4.6, sn, c, 2.1, 0.0, 0.0, 1.0]);
let r = fit(&reference, &plane(w, h, &s), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).unwrap();
let e = corner_error(&r.h, &s.inverse().unwrap(), w as f64, h as f64);
assert!(e < 0.1, "corner error {e} px, model {}", r.model);
assert!(fit(&reference, &Plane::from_fn(w, h, |_, _| 0.5), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).is_none());
}
/// Nothing to match (a flat frame, planes too small for a patch, empty ones): no fit.
#[test]
fn no_fit_when_nothing_matches() {
let h = Homography([1.0, 0.0, 0.7, 0.0, 1.0, 0.2, 0.0, 0.0, 1.0]);
let flat = Plane::from_fn(400, 300, |_, _| 0.5);
assert!(fit(&flat, &flat, &h, 4, 1, &ALL_MODELS).is_none());
let tiny = Plane::from_fn(20, 20, |x, _| x as f32);
assert!(fit(&tiny, &tiny, &h, 4, 1, &ALL_MODELS).is_none());
assert!(fit(&Plane::new(0, 0), &Plane::new(0, 0), &h, 4, 1, &ALL_MODELS).is_none());
}
#[test]
fn clipped_pixels_and_odd_gains_are_left_out() {
let img = Rgb32f::from_fn(4, 1, |x, _| [x as f32 * 0.33; 3]);
let l = luma(&img, 1.0, 1.0);
assert!(l.data[0] == 0.0 && l.data[3].is_nan(), "{:?}", l.data);
let l = luma(&img, 4.0, 1.0);
assert!(l.data[1].is_nan(), "0.33 × 4 is past 1: {:?}", l.data);
assert!(luma(&img, f32::NAN, 1.0).data[1].is_finite());
assert_eq!(radius_for(0.125), 16);
assert_eq!(radius_for(1.0), 3);
assert_eq!(radius_for(f64::NAN), 3);
}
}
+18 -1
View File
@@ -456,6 +456,14 @@ pub(crate) mod tests {
}
}
/// An exact `1/N` rational for a sub-second value whose reciprocal is a whole number (shutter speeds such as
/// 1/12800 s, which [`lightcraft_tiff::writer::rational`]'s decimal denominators cannot represent).
fn reciprocal(v: Option<f64>) -> Option<lightcraft_tiff::Value> {
let v = v.filter(|v| v.is_finite() && *v > 0.0 && *v < 1.0)?;
let inv = 1.0 / v;
((inv - inv.round()).abs() < 1e-6 && inv.round() <= u32::MAX as f64).then(|| lightcraft_tiff::Value::Rational(vec![(1, inv.round() as u32)]))
}
/// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no
/// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation,
/// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD.
@@ -483,7 +491,7 @@ pub fn write_exif(meta: &Metadata) -> Vec<u8> {
let r = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)]));
for (tag, v) in [
(t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))),
(t::EXPOSURE_TIME, r(meta.exposure_time)),
(t::EXPOSURE_TIME, reciprocal(meta.exposure_time).or_else(|| r(meta.exposure_time))),
(t::F_NUMBER, r(meta.f_number)),
(t::FOCAL_LENGTH, r(meta.focal_length)),
(t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))),
@@ -561,4 +569,13 @@ mod write_tests {
assert_eq!(back.iso, Some(iso));
}
}
#[test]
fn fast_shutter_speeds_round_trip_exactly() {
for den in [8000.0, 12_800.0, 16_000.0, 32_000.0] {
let back = read_exif(&write_exif(&Metadata { exposure_time: Some(1.0 / den), ..Default::default() }));
let got = back.exposure_time.unwrap();
assert!((1.0 / got - den).abs() < 0.5, "1/{den} s came back as 1/{}", 1.0 / got);
}
}
}
+1
View File
@@ -19,6 +19,7 @@ rayon = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
dhat = { workspace = true }
lightcraft-scenes = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
+84
View File
@@ -0,0 +1,84 @@
//! Isolated fast-guided-filter benchmark (procedural log luminance; no decoded RGB or tone median).
//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-pipeline --example guided_memory -- new`
//! Replace `new` with `old` for the materialized-coefficient reference. Optional positional arguments:
//! width height sigma iterations (defaults: 6000 4000 120 5). Append `--heap` for one dhat-counted run.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use lightcraft_pipeline::local::{BASE_EPS, guided, guided_fast, guided_fast_step};
use lightcraft_raster::blur::gaussian;
use lightcraft_raster::resample::{Filter, resize};
use lightcraft_raster::{Plane, par_join, par_rows};
use std::hint::black_box;
use std::time::Instant;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
/// Previous algorithm; kept here only to make before/after measurements reproducible.
fn materialized(p: &Plane, sigma: f32) -> Plane {
let s = guided_fast_step(sigma);
if s <= 1 {
return guided(p, sigma, BASE_EPS);
}
let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box);
let (a, b) = coefficients(&lo, sigma / s as f32);
let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear));
let mut q = Plane::new(p.width, p.height);
par_rows(&mut q.data, p.width, |y, row| {
for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) {
*v = av * pv + bv;
}
});
q
}
fn coefficients(lo: &Plane, sg: f32) -> (Plane, Plane) {
let (mean, corr) = par_join(|| gaussian(lo, sg), || gaussian(&lo.map(|v| v * v), sg));
let a = corr.zip_map(&mean, |c, m| {
let v = (c - m * m).max(0.0);
v / (v + BASE_EPS)
});
let b = mean.zip_map(&a, |m, a| m - a * m);
par_join(|| gaussian(&a, sg), || gaussian(&b, sg))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let mode = args.first().map(String::as_str).unwrap_or("new");
if !matches!(mode, "old" | "new") {
return Err("mode must be old or new".into());
}
let parse = |i: usize, default: usize| -> Result<usize, Box<dyn std::error::Error>> {
Ok(args.get(i).filter(|v| !v.starts_with("--")).map(|v| v.parse()).transpose()?.unwrap_or(default))
};
let (w, h, sigma, n) = (parse(1, 6000)?, parse(2, 4000)?, parse(3, 120)? as f32, parse(4, 5)?.max(1));
if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > 100_000_000) {
return Err("dimensions must be positive and at most 100 MP".into());
}
let heap = args.iter().any(|a| a == "--heap");
let p = Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 97.0 - 4.0 + if x > w / 2 { 2.0 } else { 0.0 });
let filter = || if mode == "old" { materialized(&p, sigma) } else { guided_fast(&p, sigma, BASE_EPS) };
// Initialize Rayon and warm code paths before counting. The source is excluded from heap stats.
black_box(filter());
let profiler = heap.then(|| dhat::Profiler::builder().testing().build());
let mut best = f64::MAX;
for _ in 0..if heap { 1 } else { n } {
let t = Instant::now();
let q = black_box(filter());
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
if heap {
let stats = dhat::HeapStats::get();
println!(
"peak_bytes={} total_bytes={} total_blocks={} retained_output_bytes={}",
stats.max_bytes,
stats.total_bytes,
stats.total_blocks,
q.data.len() * size_of::<f32>()
);
}
}
drop(profiler);
println!("{mode} {w}x{h} sigma={sigma} step={} heap={heap} min_wall_ms={best:.3}", guided_fast_step(sigma));
Ok(())
}
+6 -1
View File
@@ -450,7 +450,12 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
if dz != 0.0
&& let Some(dark) = &p.dark
{
let d = (dark.data[i] / air_pre).clamp(0.0, 1.0);
// The dark channel is a blur, not a patch minimum: near bright neighbours it credits
// dark pixels with veil they don't hold (and a windowed render borrows the whole
// frame's airlight), so the subtraction went past zero and crushed them to black
// (#2504). Never attribute more veil than the pixel's own minimum channel.
let c_min = c[0].min(c[1]).min(c[2]);
let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0);
if dz > 0.0 {
let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12);
c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0));
+1 -1
View File
@@ -139,7 +139,7 @@ pub struct RenderRequest {
pub space: OutputSpace,
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
/// Soft proofing (a proof against an output-device profile renders on the CPU; see [`Proof`]).
pub proof: Option<Proof>,
/// Render only this window of the output that `max_w × max_h` describes (a zoomed view):
/// the result is that window's pixels, as in the whole render. See [`PixelWindow`].
+102 -4
View File
@@ -178,10 +178,55 @@ fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) {
par_join(|| gaussian(&a, sigma), || gaussian(&b, sigma))
}
/// `q = a·p + b` per pixel.
/// `q = a·p + b` per pixel. Reduced coefficients are sampled with the same separable
/// weights and accumulation order as a bilinear resize, without full-size coefficient planes.
fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane {
let mut q = Plane::new(p.width, p.height);
let w = p.width;
if (a.width, a.height) != (p.width, p.height) {
use lightcraft_raster::resample::{Filter, weights};
let wx = weights(a.width, w, Filter::Bilinear);
let wy = weights(a.height, p.height, Filter::Bilinear);
// Bilinear upsampling has at most four taps (including zero-weight boundary taps).
// Keep only those horizontally sampled rows per band, reusing them for neighbouring
// output rows. Scratch scales with width and active bands, not image area.
const BAND: usize = 32;
for_rows(&mut q.data, w.saturating_mul(BAND), |band, rows| {
let mut cache: [(usize, Vec<f32>, Vec<f32>); 4] = std::array::from_fn(|_| (usize::MAX, vec![0.0; w], vec![0.0; w]));
let mut bias = vec![0.0; w];
for (dy, row) in rows.chunks_mut(w).enumerate() {
let y = band * BAND + dy;
let (top, ys) = &wy[y];
bias.fill(0.0);
for (dy, yw) in ys.iter().enumerate() {
let cy = top + dy;
let (cached_y, aa, bb) = &mut cache[cy % 4];
if *cached_y != cy {
let (ar, br) = (a.row(cy), b.row(cy));
for ((av, bv), (left, xs)) in aa.iter_mut().zip(bb.iter_mut()).zip(&wx) {
*av = 0.0;
*bv = 0.0;
for (dx, xw) in xs.iter().enumerate() {
*av += ar[left + dx] * xw;
*bv += br[left + dx] * xw;
}
}
*cached_y = cy;
}
for (av, ax) in row.iter_mut().zip(aa) {
*av += *ax * yw;
}
for (bv, bx) in bias.iter_mut().zip(bb) {
*bv += *bx * yw;
}
}
for ((v, pv), bv) in row.iter_mut().zip(p.row(y)).zip(&bias) {
*v = *v * pv + bv;
}
}
});
return q;
}
for_rows(&mut q.data, w, |y, row| {
for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) {
*v = av * pv + bv;
@@ -191,7 +236,7 @@ fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane {
}
/// Fast guided filter (He & Sun 2015): coefficients computed on a `s×` subsampled plane and
/// bilinearly upsampled; the output keeps full-resolution edges. Equivalent to [`guided`] for
/// sampled bilinearly at full resolution; the output keeps full-resolution edges. Equivalent to [`guided`] for
/// large windows at a fraction of the cost.
pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::resample::{Filter, resize};
@@ -202,8 +247,7 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane {
let (lw, lh) = (p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1));
let lo = resize(p, lw, lh, Filter::Box);
let (ma, mb) = guided_coeffs(&lo, sigma / s as f32, eps);
let up = |c: &Plane| resize(c, p.width, p.height, Filter::Bilinear);
guided_apply(p, &up(&ma), &up(&mb))
guided_apply(p, &ma, &mb)
}
/// Separable median (`2r+1` px along each row, then each column; clamped edges). It keeps the
@@ -758,6 +802,60 @@ mod tests {
mod fast_tests {
use super::*;
/// The previous fast path, including its two materialized coefficient resizes.
fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::resample::{Filter, resize};
let s = guided_fast_step(sigma);
if s <= 1 {
return guided(p, sigma, eps);
}
let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box);
let (a, b) = guided_coeffs(&lo, sigma / s as f32, eps);
let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear));
Plane::from_fn(p.width, p.height, |x, y| a.get(x, y) * p.get(x, y) + b.get(x, y))
}
#[test]
fn sampled_coefficients_match_materialized_resizes() {
for (w, h) in [(0, 0), (1, 1), (1, 19), (23, 1), (2, 3), (17, 13), (64, 48), (161, 119)] {
let planes = [
Plane::filled(w, h, -3.25),
Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 7.0 - 8.0),
Plane::from_fn(w, h, |x, y| {
if x == w / 2 && y == h / 2 {
9.0
} else if x < w / 2 {
-5.0
} else {
2.0
}
}),
];
// Step 1 fallback, every small step, Draft's cap, and the maximum step (16).
for sigma in [0.0, 2.99, 5.99, 6.0, 9.0, 12.0, 24.0, 48.0, 200.0] {
for eps in [BASE_EPS, CLARITY_EPS, 0.0001] {
for p in &planes {
let expected = resized_reference(p, sigma, eps);
let actual = guided_fast(p, sigma, eps);
assert_eq!((actual.width, actual.height), (w, h));
assert_eq!(actual.data, expected.data, "{w}x{h}, sigma {sigma}, eps {eps}");
}
}
}
}
}
#[test]
fn sampled_tone_base_keeps_the_median_before_the_guided_filter() {
let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 });
for sigma in [6.0, 24.0, 120.0] {
for detail in [0, 1, 3, BASE_DETAIL_MAX] {
let median = median_hv(&p, detail);
assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}");
}
}
}
#[test]
fn fast_guided_close_to_reference() {
let p = Plane::from_fn(160, 120, |x, y| ((x as f32 * 0.13).sin() + (y as f32 * 0.07).cos()) * 2.0 + if x > 80 { 1.5 } else { 0.0 });
+32
View File
@@ -75,6 +75,38 @@ fn directional_sliders() {
assert!(at("effects.dehaze", -100.0) != base);
}
/// Dehaze crushed dark regions next to bright ones to black: the dark channel is a blur, not a
/// patch minimum, so near the boundary it credited the dark pixels with the bright side's veil and
/// the subtraction went past zero (#2504). The veil credited to a pixel is now bounded by its own
/// minimum channel, so a uniform dark region renders uniformly under dehaze.
#[test]
fn dehaze_does_not_crush_dark_regions_next_to_bright_ones() {
// a dark field against a bright hazy sky; the blur bleeds the sky's dark channel ~10 px into
// the field, which is where the blotches formed
let src = Rgb32f::from_fn(240, 160, |x, _| if x < 120 { [0.02, 0.03, 0.012] } else { [0.45, 0.5, 0.55] });
let mut s = DevelopSettings::default();
s.effects.dehaze = 80.0;
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 160)).image;
for ch in 0..3 {
let mut v: Vec<u8> = Vec::with_capacity(160 * 100);
for y in 0..160 {
for x in 0..100 {
v.push(r.get(x, y)[ch]);
}
}
v.sort_unstable();
let mid = v[v.len() / 2] as i32;
// a field rendered all black would pass the comparison below vacuously
assert!(mid > 2, "the dark field itself is crushed in ch {ch}: level {mid}");
for y in 0..160 {
for x in 0..120 {
let p = r.get(x, y)[ch] as i32;
assert!(p >= mid - 2, "crushed at {x},{y} ch {ch}: {p} vs field level {mid}");
}
}
}
}
#[test]
fn resolution_independence_of_local_contrast() {
// Clarity at two preview sizes should give similar results after downscaling.
+39
View File
@@ -407,3 +407,42 @@ fn auto_mask_strokes_match_the_whole_render() {
};
check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1);
}
/// The margin the loupe's windows carry around what they show at this size (`region::margin_for`:
/// 4.5 % of the long edge).
const OVERLAY_INSET: usize = W * 45 / 1000;
/// [`check`] for a diagnostic overlay, with the photo as a raw (Visualize HDR needs one), keeping the
/// loupe's margin in from the window's edges.
fn check_overlay(name: &str, s: &DevelopSettings, overlay: crate::Overlay, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let info = SourceInfo { raw: true, ..Default::default() };
let full_req = RenderRequest { overlay, ..RenderRequest::fit(W, H) };
let f = render(&scene(), &info, s, &full_req).image;
let w = render(&scene(), &info, s, &RenderRequest { window: Some(win), ..full_req }).image;
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
assert_ne!(f.data, render(&scene(), &info, s, &RenderRequest::fit(W, H)).image.data, "{name}: the overlay draws something");
let (max, mean) = compare_inset(&f, &w, win, OVERLAY_INSET);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
// Given a diagnostic overlay (Visualize Spots, a Point Color range, Visualize HDR), a window shows
// the same pixels as the whole-frame render, so the loupe can draw them as a zoom window
#[test]
fn diagnostic_overlays_in_a_window_match_the_full_render() {
use crate::Overlay;
let mut spots = DevelopSettings::default();
spots.detail.sharpen_amount = 60.0;
// a binary threshold: a pixel at the threshold may flip on a one-level difference underneath
check_overlay("spots", &spots, Overlay::Spots(70), MID, 255, 0.05);
for (x, y) in [(0, 0), (W - 400, H - 300)] {
check_overlay("spots at a corner", &spots, Overlay::Spots(70), PixelWindow { x, y, w: 400, h: 300 }, 255, 0.05);
}
let mut pc = DevelopSettings::default();
pc.point_colors.push(lightcraft_develop::PointColor { lum: 0.6, chroma: 0.08, hue: 60.0, hue_shift: 40.0, ..Default::default() });
check_overlay("point colour range", &pc, Overlay::PointColorRange(0), MID, 1, 0.1);
let mut hdr = DevelopSettings::default();
hdr.hdr.enabled = true;
hdr.hdr.max_ev = 3.0;
hdr.light.exposure = 1.5;
check_overlay("hdr range", &hdr, Overlay::HdrRange, MID, 1, 0.1);
}
+1 -1
View File
@@ -2,7 +2,7 @@
name = "lightcraft-ui-egui"
version.workspace = true
edition.workspace = true
rust-version = "1.95"
rust-version.workspace = true
license.workspace = true
description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)"
+9 -1
View File
@@ -253,6 +253,7 @@
"new, inside {parent}": "nové, pod {parent}",
"Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.",
"Inside: {names}": "Uvnitř: {names}",
" in {g} group{}": " ve skupinách: {g}{:.0}",
" · kept, not applied: {}": " · zachováno, nepoužito: {}",
" — kept but not applied: {}": " — zachováno, ale nepoužito: {}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Procházení složek – složky: {folders}, soubory: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí.",
"Drop to review {n} item{} for import": "Pusťte pro kontrolu před importem (položek: {n}){:.0}",
"Drop to import {n} item{}": "Pusťte pro import (položek: {n}){:.0}",
"Drop to import {n} preset{}": "Pusťte pro import přednastavení: {n}{:.0}",
"Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}",
"Added {n} photo{}": "Přidáno fotografií: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})"
}
+20 -9
View File
@@ -374,7 +374,6 @@
"Clear Filters": "Zrušit filtry",
"Clear Guides": "Vymazat vodítka",
"Clear History": "Vymazat historii",
"Clear Preview Cache": "Vymazat mezipaměť náhledů",
"Clear Quick Collection": "Vyprázdnit rychlou kolekci",
"Clear Recent Libraries": "Vymazat nedávné knihovny",
"Clear Reference": "Zrušit referenční fotografii",
@@ -425,7 +424,6 @@
"Company": "Společnost",
"Compare": "Porovnat",
"Compare (Shift+C)": "Porovnat (Shift+C)",
"Compare Before and After": "Porovnat před a po",
"Compare needs at least two photos": "Porovnání vyžaduje alespoň dvě fotografie",
"Component options": "Možnosti součásti",
"Compression": "Komprese",
@@ -1585,7 +1583,6 @@
"Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Inteligentní alba se aktualizují automaticky: změňte jejich pravidla nebo použijte Fotografie → Odstranit fotografii",
"LightCraft stopped": "LightCraft se neočekávaně ukončil",
"Show Focus Point": "Zobrazit zaostřovací bod",
"Zoom to Focus Point": "Přiblížit na zaostřovací bod",
"Focus": "Zaostření",
"Focus point": "Zaostřovací bod",
@@ -1754,18 +1751,13 @@
"Short Edge": "Kratší hrana",
"Show": "Zobrazit",
"Show All": "Zobrazit vše",
"Show Clipping": "Zobrazit ořezání tónů",
"Show Deghost Overlay": "Zobrazit překrytí odstranění duchů",
"Show Hidden Local Locations": "Zobrazit skrytá místní umístění",
"Show Luminance Map": "Zobrazit mapu jasu",
"Show Mask": "Zobrazit masku",
"Show Mask Overlay": "Zobrazit překrytí masky",
"Show All Mask Overlays": "Zobrazit všechna překrytí masek",
"Show all overlays": "Zobrazit všechna překrytí",
"Show Mask Pins": "Zobrazit body masek",
"Show Original": "Zobrazit originál",
"Show Person": "Zobrazit osobu",
"Show Photo Counts": "Zobrazit počty fotografií",
"Show Photos with Keyword": "Zobrazit fotografie s klíčovým slovem",
"Show Pins": "Zobrazit body",
"Show Sidebar Section": "Zobrazit sekci postranního panelu",
@@ -2394,5 +2386,24 @@
"End Slideshow": "Ukončit prezentaci",
"Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…",
"Manage Effect Presets…": "Spravovat přednastavení efektů…",
"Export Effect Presets…": "Exportovat přednastavení efektů…"
"Export Effect Presets…": "Exportovat přednastavení efektů…",
"Before/After Side by Side": "Před/Po vedle sebe",
"Clipping": "Ořezání tónů",
"Mask Overlay": "Překrytí masky",
"All Mask Overlays": "Všechna překrytí masek",
"Mask Pins": "Body masek",
"Photo Counts": "Počty fotografií",
"Focus Point": "Bod zaostření",
"Layout Image": "Obrázek rozvržení",
"Detail View": "Zobrazení detailu",
"Edit Panel": "Panel Úpravy",
"Close the open dialog, then drop the files again": "Zavřete otevřené okno, pak soubory pusťte znovu",
"Close the open dialog to drop files here": "Zavřete otevřené okno, aby sem šlo soubory pustit",
"Drop to open the face model": "Pusťte pro otevření modelu obličejů",
"Drop to import": "Pusťte pro import",
"Dropped Files": "Přetažené soubory",
"Import dropped files without review": "Importovat přetažené soubory bez kontroly",
"An import is running": "Import už běží",
"An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí",
"Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky"
}
+9 -1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.",
"Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…",
"Inside: {names}": "Darin: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?",
"There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?",
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
"Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Drop to review {n} item{} for import": "Ablegen, um vor dem Import zu prüfen (Objekte: {n}){:.0}",
"Drop to import {n} item{}": "Ablegen zum Importieren (Objekte: {n}){:.0}",
"Drop to import {n} preset{}": "Ablegen zum Importieren von Vorgaben: {n}{:.0}",
"Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}",
"Added {n} photo{}": "{n} Foto{} hinzugefügt",
"Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})"
}
+23 -9
View File
@@ -235,7 +235,6 @@
"Clear Filters": "Filter löschen",
"Clear Guides": "Hilfslinien löschen",
"Clear History": "Verlauf löschen",
"Clear Preview Cache": "Vorschau-Cache leeren",
"Clear Quick Collection": "Schnellsammlung leeren",
"Clear Reference": "Referenz entfernen",
"Clear all": "Alles löschen",
@@ -289,7 +288,6 @@
"Company": "Unternehmen",
"Compare": "Vergleichen",
"Compare (Shift+C)": "Vergleichen (Umschalt+C)",
"Compare Before and After": "Vorher und Nachher vergleichen",
"Compare needs at least two photos": "Zum Vergleichen sind mindestens zwei Fotos erforderlich",
"Component options": "Komponentenoptionen",
"Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimierte Nikon-NEFs, verlustfrei oder verlustbehaftet mit 12 oder 14 Bit, etwa D3200, D5100, D7000, D750, D850 und Z 50,",
@@ -1207,6 +1205,9 @@
"Refresh View": "Ansicht aktualisieren",
"Reject": "Ablehnen",
"Reject below focus": "Unter Schärfegrenzwert ablehnen",
"Burst: shots at most this far apart": "Serie: Aufnahmen höchstens so weit auseinander",
"Burst: how alike (loose … strict)": "Serie: wie ähnlich (locker … streng)",
"Lower it when a panning or moving burst splits into several groups.": "Senken, wenn eine Mitzieher- oder Bewegungsserie in mehrere Gruppen zerfällt.",
"Rejected": "Abgelehnt",
"Rejects": "Abgelehnte Fotos",
"Reliability": "Zuverlässigkeit",
@@ -1408,17 +1409,12 @@
"Short Edge": "Kurze Kante",
"Show": "Anzeigen",
"Show All": "Alle einblenden",
"Show Clipping": "Beschnitt anzeigen",
"Show Deghost Overlay": "Geisterbildüberlagerung anzeigen",
"Show Hidden Local Locations": "Ausgeblendete lokale Orte anzeigen",
"Show Luminance Map": "Luminanzkarte anzeigen",
"Show Mask": "Maske anzeigen",
"Show Mask Overlay": "Maskenüberlagerung anzeigen",
"Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen",
"Show all overlays": "Alle Überlagerungen anzeigen",
"Show Mask Pins": "Maskenpins anzeigen",
"Show Original": "Original anzeigen",
"Show Photo Counts": "Fotoanzahl anzeigen",
"Show Photos with Keyword": "Fotos mit Stichwort anzeigen",
"Show Pins": "Pins anzeigen",
"Show Source": "Quelle anzeigen",
@@ -2316,7 +2312,6 @@
"Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen",
"LightCraft stopped": "LightCraft wurde beendet",
"Show Focus Point": "Fokuspunkt anzeigen",
"Zoom to Focus Point": "Auf Fokuspunkt zoomen",
"Focus": "Fokus",
"Focus point": "Fokuspunkt",
@@ -3423,5 +3418,24 @@
"favourites across restarts (issue #328).": "über Neustarts hinweg Favoriten (Issue #328).",
"Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "Optionales entferntes SAM 3: Der native Editor bleibt lokal, und Objekt-, Beschreibungs- und Detail-",
"inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.",
"See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference)."
"See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).",
"Before/After Side by Side": "Vorher/Nachher nebeneinander",
"Clipping": "Tonwertbeschneidung",
"Mask Overlay": "Maskenüberlagerung",
"All Mask Overlays": "Alle Maskenüberlagerungen",
"Mask Pins": "Maskenpins",
"Photo Counts": "Fotoanzahl",
"Focus Point": "Fokuspunkt",
"Layout Image": "Layoutbild",
"Detail View": "Detailansicht",
"Edit Panel": "Bedienfeld „Bearbeiten“",
"Close the open dialog, then drop the files again": "Schließe den geöffneten Dialog und lege die Dateien dann erneut ab",
"Close the open dialog to drop files here": "Schließe den geöffneten Dialog, um hier Dateien abzulegen",
"Drop to open the face model": "Ablegen, um das Gesichtsmodell zu öffnen",
"Drop to import": "Ablegen zum Importieren",
"Dropped Files": "Abgelegte Dateien",
"Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren",
"An import is running": "Ein Import läuft",
"An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist",
"Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst"
}
+9 -1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.",
"Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…",
"Inside: {names}": "Dentro: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?",
"There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?",
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
"Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.",
"Drop to review {n} item{} for import": "Suelta para revisar {n} elemento{} antes de importar",
"Drop to import {n} item{}": "Suelta para importar {n} elemento{}",
"Drop to import {n} preset{}": "Suelta para importar {n} ajuste{0} preestablecido{0}",
"Could not read {name}: {e}": "No se pudo leer {name}: {e}",
"Added {n} photo{}": "{n} foto{0} añadida{0}",
"Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})"
}
+22 -9
View File
@@ -228,7 +228,6 @@
"Clear Filters": "Borrar filtros",
"Clear Guides": "Borrar guías",
"Clear History": "Borrar historial",
"Clear Preview Cache": "Vaciar caché de vistas previas",
"Clear Quick Collection": "Borrar colección rápida",
"Clear Reference": "Borrar referencia",
"Clear all": "Borrar todo",
@@ -279,7 +278,7 @@
"Company": "Empresa",
"Compare": "Comparar",
"Compare (Shift+C)": "Comparar (Mayús+C)",
"Compare Before and After": "Comparar antes y después",
"Before/After Side by Side": "Antes/Después lado a lado",
"Compare needs at least two photos": "Comparar requiere al menos dos fotos",
"Component options": "Opciones del componente",
"Compression": "Compresión",
@@ -292,6 +291,8 @@
"Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar",
"Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco",
"Dialogs": "Diálogos",
"Close a dialog by clicking outside it": "Cerrar un diálogo al hacer clic fuera de él",
"Constrain Crop": "Restringir recorte",
"Contact Sheet PDF": "PDF de hoja de contactos",
"Continue": "Continuar",
@@ -1413,15 +1414,15 @@
"Short Edge": "Lado corto",
"Show": "Mostrar",
"Show All": "Mostrar todo",
"Show Clipping": "Mostrar recorte de tonos",
"Clipping": "Recorte de tonos",
"Show Deghost Overlay": "Mostrar superposición de eliminación de fantasmas",
"Show Hidden Local Locations": "Mostrar ubicaciones ocultas de Local",
"Show Luminance Map": "Mostrar mapa de luminancia",
"Show Mask": "Mostrar máscara",
"Show Mask Overlay": "Mostrar superposición de máscara",
"Show All Mask Overlays": "Mostrar todas las superposiciones de máscara",
"Mask Overlay": "Superposición de máscara",
"All Mask Overlays": "Todas las superposiciones de máscara",
"Show all overlays": "Mostrar todas las superposiciones",
"Show Mask Pins": "Mostrar chinchetas de máscara",
"Mask Pins": "Chinchetas de máscara",
"Show Original": "Mostrar original",
"Show Person": "Mostrar persona",
"Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.",
@@ -1430,7 +1431,7 @@
"Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus",
"Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,",
"Sort Albums A–Z": "Ordenar álbumes A–Z",
"Show Photo Counts": "Mostrar recuento de fotos",
"Photo Counts": "Recuento de fotos",
"Show Photos with Keyword": "Mostrar fotos con la palabra clave",
"Show Pins": "Mostrar chinchetas",
"Show Source": "Mostrar origen",
@@ -2314,7 +2315,7 @@
"Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto",
"LightCraft stopped": "LightCraft se detuvo",
"Show Focus Point": "Mostrar punto de enfoque",
"Focus Point": "Punto de enfoque",
"Zoom to Focus Point": "Zoom al punto de enfoque",
"Focus": "Enfoque",
"Focus point": "Punto de enfoque",
@@ -2342,5 +2343,17 @@
"Checking the GPU…": "Comprobando la GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.",
"Checking the GPU for faces": "Comprobando la GPU para las caras"
"Checking the GPU for faces": "Comprobando la GPU para las caras",
"Layout Image": "Imagen de diseño",
"Detail View": "Vista de detalle",
"Edit Panel": "Panel Editar",
"Close the open dialog, then drop the files again": "Cierra el cuadro de diálogo abierto y vuelve a soltar los archivos",
"Close the open dialog to drop files here": "Cierra el cuadro de diálogo abierto para soltar archivos aquí",
"Drop to open the face model": "Suelta para abrir el modelo de caras",
"Drop to import": "Suelta para importar",
"Dropped Files": "Archivos soltados",
"Import dropped files without review": "Importar los archivos soltados sin revisión",
"An import is running": "Hay una importación en curso",
"An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine",
"Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos"
}
+9 -1
View File
@@ -102,6 +102,7 @@
"Imported {} photo{}": "Tuotuja kuvia: {}{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Jonossa tekoälykohinanpoistoon ({ahead} edellä)…",
"Insert at the cursor (also written {})": "Lisää kohdistimen kohtaan (kirjoitetaan myös {})",
"Inside: {names}": "Sisällä: {names}",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se poistetaan {count} kuvalta{:.0}, alla olevat avainsanat mukaan lukien. Kumoa palauttaa sen.",
"Keywords of {n} selected photos": "Valittujen kuvien ({n}) avainsanat",
"Label Set: {name}": "Merkintäjoukko: {name}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” on jo henkilö. Yhdistäminen tekee kahdesta yhden henkilön; Kumoa erottaa ne uudelleen.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… ja {more} lisää",
"…and {more} more": "…ja {more} lisää"
"…and {more} more": "…ja {more} lisää",
"Added {n} photo{}": "Lisättyjä kuvia: {n}{:.0}",
"Could not read {name}: {e}": "Tiedostoa {name} ei voitu lukea: {e}",
"Drop to import {n} item{}": "Pudota tuodaksesi (kohteita: {n}){:.0}",
"Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}",
"Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",
"Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"
}
+20 -9
View File
@@ -306,7 +306,6 @@
"Clear Filters": "Tyhjennä suodattimet",
"Clear Guides": "Tyhjennä apuviivat",
"Clear History": "Tyhjennä historia",
"Clear Preview Cache": "Tyhjennä esikatselun välimuisti",
"Clear Quick Collection": "Tyhjennä pikakokoelma",
"Clear Recent Libraries": "Tyhjennä viimeisimmät kirjastot",
"Clear Reference": "Tyhjennä vertailukuva",
@@ -372,7 +371,6 @@
"Company": "Yritys",
"Compare": "Vertaa",
"Compare (Shift+C)": "Vertaa (Shift+C)",
"Compare Before and After": "Vertaa ennen ja jälkeen",
"Compare needs at least two photos": "Vertailu vaatii vähintään kaksi kuvaa",
"Component options": "Komponentin asetukset",
"Compression": "Pakkaus",
@@ -1822,19 +1820,13 @@
"Show": "Näytä",
"Show / hide": "Näytä / piilota",
"Show All": "Näytä kaikki",
"Show All Mask Overlays": "Näytä kaikki maskien peittokuvat",
"Show Clipping": "Näytä leikkautuminen",
"Show Deghost Overlay": "Näytä haamukuvien poiston peittokuva",
"Show Focus Point": "Näytä tarkennuspiste",
"Show Hidden Local Locations": "Näytä piilotetut paikalliset sijainnit",
"Show Layout Image": "Näytä asettelukuva",
"Show Luminance Map": "Näytä kirkkauskartta",
"Show Mask": "Näytä maski",
"Show Mask Overlay": "Näytä maskin peittokuva",
"Show Mask Pins": "Näytä maskin nastat",
"Show Original": "Näytä alkuperäinen",
"Show Person": "Näytä henkilö",
"Show Photo Counts": "Näytä kuvamäärät",
"Show Photos with Keyword": "Näytä kuvat, joilla on avainsana",
"Show Pins": "Näytä nastat",
"Show Sidebar Section": "Näytä sivupalkin osio",
@@ -2311,5 +2303,24 @@
"− harder border · + softer border": "− terävämpi reuna · + pehmeämpi reuna",
"≤ at most": "≤ enintään",
"≥ at least": "≥ vähintään",
"★ Favorites": "★ Suosikit"
"★ Favorites": "★ Suosikit",
"All Mask Overlays": "Kaikki maskien peittokuvat",
"Before/After Side by Side": "Ennen/jälkeen vierekkäin",
"Clipping": "Leikkautuminen",
"Detail View": "Tarkastelunäkymä",
"Edit Panel": "Muokkauspaneeli",
"Focus Point": "Tarkennuspiste",
"Layout Image": "Asettelukuva",
"Mask Overlay": "Maskin peittokuva",
"Mask Pins": "Maskin nastat",
"Photo Counts": "Kuvamäärät",
"An import is running": "Tuonti on käynnissä",
"An import is running. Drop photos when it has finished": "Tuonti on käynnissä. Pudota kuvat, kun se on valmis",
"Close the open dialog to drop files here": "Sulje avoin valintaikkuna pudottaaksesi tiedostot tähän",
"Close the open dialog, then drop the files again": "Sulje avoin valintaikkuna ja pudota tiedostot sitten uudelleen",
"Drop to import": "Pudota tuodaksesi",
"Drop to open the face model": "Pudota avataksesi kasvomallin",
"Dropped Files": "Pudotetut tiedostot",
"Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",
"Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"
}
+11 -3
View File
@@ -51,7 +51,7 @@
"Example: {example}": "Exemple : {example}",
"Exported {n} photo{}": "{n} photo{} exportée(s)",
"Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}",
"Exported {} point curve preset{}": "{} préréglage{} de courbe de points exporté(s)",
"Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)",
"Exported {} preset{}": "{} préréglage{} exporté(s)",
"Exporting {} of {total}": "Exportation : {} sur {total}",
"Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)",
@@ -62,7 +62,7 @@
"Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)",
"Import failed: {e}": "Échec de l’importation : {e}",
"Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}",
"Imported {n} point curve preset{}": "{n} préréglage{} de courbe de points importé(s)",
"Imported {n} point curve preset{}": "{n} préréglage{} de courbe à points importé(s)",
"Imported {n} preset{}": "{n} préréglage{} importé(s)",
"Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)",
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Supprimer le mot-clé « {name} » ?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Il est retiré de {count} photo{}, avec les mots-clés qu’il contient. Annuler le rétablit.",
"Create Keyword Tag Inside “{name}”…": "Créer un mot-clé dans « {name} »…",
"Inside: {names}": "À l’intérieur : {names}",
"“{parent}” has a “{name}” already. Merge the two?": "« {parent} » contient déjà « {name} ». Fusionner les deux ?",
"There is a “{name}” at the top level already. Merge the two?": "Il existe déjà « {name} » au premier niveau. Fusionner les deux ?",
"Added “{keyword}” to {n} photo{}": "« {keyword} » ajouté à {n} photo{}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
"Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{0} déplacée{0} vers « {1} ». Modifier → Annuler les remet en place.",
"Drop to review {n} item{} for import": "Déposez pour vérifier {n} élément{} avant l'import",
"Drop to import {n} item{}": "Déposez pour importer {n} élément{}",
"Drop to import {n} preset{}": "Déposez pour importer {n} préréglage{}",
"Could not read {name}: {e}": "Impossible de lire {name} : {e}",
"Added {n} photo{}": "{n} photo{} ajoutée(s)",
"Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?",
"Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})"
}
+88 -41
View File
@@ -26,7 +26,7 @@
"Add Folder to Local": "Ajouter le dossier à Local",
"Add Mask": "Ajouter un masque",
"Add Object Click": "Ajouter un clic sur l'objet",
"Add Point Color Sample": "Ajouter un échantillon de couleur ponctuelle",
"Add Point Color Sample": "Ajouter un échantillon de point de couleur",
"Add Red Eye Correction": "Ajouter une correction des yeux rouges",
"Add Remove Spot": "Ajouter une suppression de tache",
"Add Selected Photos": "Ajouter les photos sélectionnées",
@@ -76,21 +76,21 @@
"Apply Color Label Set": "Appliquer un jeu d'étiquettes de couleur",
"Apply Filter Preset": "Appliquer un préréglage de filtre",
"Apply Metadata Preset": "Appliquer un préréglage de métadonnées",
"Apply Point Curve Preset": "Appliquer un préréglage de courbe de points",
"Apply Point Curve Preset": "Appliquer un préréglage de courbe à points",
"Apply Preset": "Appliquer un préréglage",
"Apply Settings JSON": "Appliquer des réglages JSON",
"Approved": "Approuvée",
"Aqua": "Aigue-marine",
"Aqua Hue": "Teinte aigue-marine",
"Aqua Luminance": "Luminance aigue-marine",
"Aqua Saturation": "Saturation aigue-marine",
"Aqua": "Turquoise",
"Aqua Hue": "Teinte turquoise",
"Aqua Luminance": "Luminance turquoise",
"Aqua Saturation": "Saturation turquoise",
"ArtCraft Website": "Site web d'ArtCraft",
"ArtCraft — more creative apps": "ArtCraft : d'autres applications créatives",
"As Shot": "Tel quel",
"As shot": "Tel quel",
"Ascending": "Croissant",
"Aspect": "Format",
"Aspect Ratio": "Rapport d'aspect",
"Aspect Ratio": "Ratio de format",
"Assisted Culling": "Tri assisté",
"Assisted Culling…": "Tri assisté…",
"Auto": "Auto",
@@ -142,7 +142,7 @@
"Best of Similar Shots": "Meilleure des prises similaires",
"Binary assets added": "Ressources binaires ajoutées",
"Binary assets removed": "Ressources binaires supprimées",
"Bit depth": "Profondeur de bits",
"Bit depth": "Profondeur de couleur",
"Black & White": "Noir et blanc",
"Blacks": "Noirs",
"Bleach Bypass": "Bleach bypass",
@@ -214,7 +214,6 @@
"Clear Filters": "Effacer les filtres",
"Clear Guides": "Effacer les repères",
"Clear History": "Effacer l'historique",
"Clear Preview Cache": "Vider le cache des aperçus",
"Clear Quick Collection": "Vider la collection rapide",
"Clear Reference": "Effacer la référence",
"Clear all": "Tout effacer",
@@ -264,7 +263,7 @@
"Company": "Société",
"Compare": "Comparer",
"Compare (Shift+C)": "Comparer (Maj+C)",
"Compare Before and After": "Comparer avant et après",
"Before/After Side by Side": "Avant/Après côte à côte",
"Compare needs at least two photos": "Comparer nécessite au moins deux photos",
"Component options": "Options du composant",
"Compression": "Compression",
@@ -374,9 +373,9 @@
"Delete Permanently": "Supprimer définitivement",
"Delete Photo": "Supprimer la photo",
"Delete Photos": "Supprimer les photos",
"Delete Point Color": "Supprimer la couleur ponctuelle",
"Delete Point Color Sample": "Supprimer l'échantillon de couleur ponctuelle",
"Delete Point Curve Preset": "Supprimer le préréglage de courbe de points",
"Delete Point Color": "Supprimer le point de couleur",
"Delete Point Color Sample": "Supprimer l'échantillon de point de couleur",
"Delete Point Curve Preset": "Supprimer le préréglage de courbe à points",
"Delete Preset": "Supprimer le préréglage",
"Delete Red Eye": "Supprimer les yeux rouges",
"Delete Red Eye Correction": "Supprimer la correction des yeux rouges",
@@ -470,8 +469,8 @@
"Export As…": "Exporter sous…",
"Export Group…": "Exporter le groupe…",
"Export Now": "Exporter maintenant",
"Export Point Curve Presets": "Exporter les préréglages de courbe de points",
"Export Point Curve Presets…": "Exporter les préréglages de courbe de points…",
"Export Point Curve Presets": "Exporter les préréglages de courbe à points",
"Export Point Curve Presets…": "Exporter les préréglages de courbe à points…",
"Export Presets": "Exporter les préréglages",
"Export Presets…": "Exporter les préréglages…",
"Export User Presets…": "Exporter les préréglages utilisateur…",
@@ -602,12 +601,12 @@
"Grid Info": "Informations de la grille",
"Gritty Street": "Rue brute",
"Group": "Groupe",
"Group by Date: Automatic": "Regrouper par date : automatique",
"Group by Day": "Regrouper par jour",
"Group by Month": "Regrouper par mois",
"Group by Year": "Regrouper par année",
"Group by date": "Regrouper par date",
"Group into Stack": "Regrouper en pile",
"Group by Date: Automatic": "Grouper par date : automatique",
"Group by Day": "Grouper par jour",
"Group by Month": "Grouper par mois",
"Group by Year": "Grouper par année",
"Group by date": "Grouper par date",
"Group into Stack": "Grouper en pile",
"Group name": "Nom du groupe",
"Guided": "Guidé",
"Guided Upright": "Upright guidé",
@@ -664,8 +663,8 @@
"Import Lightroom Catalog…": "Importer un catalogue Lightroom…",
"Import Photos": "Importer des photos",
"Import Photos…": "Importer des photos…",
"Import Point Curve Presets": "Importer des préréglages de courbe de points",
"Import Point Curve Presets…": "Importer des préréglages de courbe de points…",
"Import Point Curve Presets": "Importer des préréglages de courbe à points",
"Import Point Curve Presets…": "Importer des préréglages de courbe à points…",
"Import Presets": "Importer des préréglages",
"Import Presets…": "Importer des préréglages…",
"Import Profiles": "Importer des profils",
@@ -1037,10 +1036,10 @@
"Picks": "Sélectionnées",
"Pictures": "Images",
"Pixels": "Pixels",
"Point Color": "Couleur ponctuelle",
"Point Curve": "Courbe de points",
"Point Curve Presets": "Préréglages de courbe de points",
"Point curve": "Courbe de points",
"Point Color": "Point de couleur",
"Point Curve": "Courbe à points",
"Point Curve Presets": "Préréglages de courbe à points",
"Point curve": "Courbe à points",
"Portrait": "Portrait",
"Position": "Position",
"Preferences": "Préférences",
@@ -1257,8 +1256,8 @@
"Save Metadata Preset": "Enregistrer le préréglage de métadonnées",
"Save Metadata Preset…": "Enregistrer le préréglage de métadonnées…",
"Save Metadata to File": "Enregistrer les métadonnées dans le fichier",
"Save Point Curve Preset": "Enregistrer le préréglage de courbe de points",
"Save Point Curve…": "Enregistrer la courbe de points…",
"Save Point Curve Preset": "Enregistrer le préréglage de courbe à points",
"Save Point Curve…": "Enregistrer la courbe à points…",
"Save as Preset": "Enregistrer comme préréglage",
"Save as Set…": "Enregistrer comme jeu…",
"Save as Smart Album…": "Enregistrer comme album intelligent…",
@@ -1308,7 +1307,7 @@
"Set Image Zoom and Pan": "Définir le zoom et le déplacement de l'image",
"Set Keyboard Shortcut": "Définir le raccourci clavier",
"Set Mask Adjustments": "Définir les réglages du masque",
"Set Point Curve": "Définir la courbe de points",
"Set Point Curve": "Définir la courbe à points",
"Set Profile": "Définir le profil",
"Set Rating": "Définir la note",
"Set Top of Stack": "Définir le haut de la pile",
@@ -1343,17 +1342,17 @@
"Short Edge": "Petit côté",
"Show": "Afficher",
"Show All": "Tout afficher",
"Show Clipping": "Afficher l'écrêtage",
"Clipping": "Écrêtage",
"Show Deghost Overlay": "Afficher l'incrustation de suppression des images fantômes",
"Show Hidden Local Locations": "Afficher les emplacements Local masqués",
"Show Luminance Map": "Afficher la carte de luminance",
"Show Mask": "Afficher le masque",
"Show Mask Overlay": "Afficher l'incrustation du masque",
"Show All Mask Overlays": "Afficher toutes les superpositions de masque",
"Mask Overlay": "Incrustation du masque",
"All Mask Overlays": "Toutes les incrustations de masque",
"Show all overlays": "Afficher toutes les superpositions",
"Show Mask Pins": "Afficher les épingles de masque",
"Mask Pins": "Épingles de masque",
"Show Original": "Afficher l'original",
"Show Photo Counts": "Afficher le nombre de photos",
"Photo Counts": "Nombre de photos",
"Show Photos with Keyword": "Afficher les photos avec le mot-clé",
"Show Pins": "Afficher les épingles",
"Show Source": "Afficher la source",
@@ -1458,7 +1457,7 @@
"Targeted adjustment: drag up/down on the photo to adjust the curve there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster la courbe à cet endroit",
"Teal & Amber": "Bleu-vert et ambre",
"Teal & Orange": "Bleu-vert et orange",
"Temp": "Temp.",
"Temp": "Température",
"Temperature": "Température",
"Template": "Modèle",
"Temporary session — your library isn't open, and nothing you do here is saved.": "Session temporaire : votre bibliothèque n'est pas ouverte et rien de ce que vous faites ici n'est enregistré.",
@@ -1930,7 +1929,7 @@
"Finding faces": "Recherche des visages",
"Synchronizing folder": "Synchronisation du dossier",
"Looking for changes": "Recherche des modifications",
"Arrange Panel": "Disposer le panneau",
"Arrange Panel": "Modifier le panneau",
"Close panel": "Fermer le panneau",
"Float panel": "Panneau flottant",
"Move to group": "Déplacer vers le groupe",
@@ -1956,7 +1955,7 @@
"Perceptual": "Perceptif",
"Relative": "Relatif",
"Simulate Paper & Ink": "Simuler le papier et l'encre",
"Content-Aware": "Contenu pris en compte",
"Content-Aware": "Selon le contenu",
"Sidebar Sections": "Sections de la barre latérale",
"Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale",
"Hide This Section": "Masquer cette section",
@@ -2249,7 +2248,7 @@
"Intro screen": "Écran d'introduction",
"Ending screen": "Écran de fin",
"Shows": "Affiche",
"Colour only": "Couleur seule",
"Colour only": "Couleur unie",
"Override colour": "Remplacer la couleur",
"Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)",
"Duration": "Durée",
@@ -2312,7 +2311,7 @@
"Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo",
"LightCraft stopped": "LightCraft s’est arrêté",
"Show Focus Point": "Afficher le point de mise au point",
"Focus Point": "Point de mise au point",
"Zoom to Focus Point": "Zoomer sur le point de mise au point",
"Focus": "Mise au point",
"Focus point": "Point de mise au point",
@@ -2340,5 +2339,53 @@
"Checking the GPU…": "Vérification du GPU…",
"CPU": "Processeur",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.",
"Checking the GPU for faces": "Vérification du GPU pour les visages"
"Checking the GPU for faces": "Vérification du GPU pour les visages",
"Layout Image": "Image de mise en page",
"Detail View": "Vue de détail",
"Edit Panel": "Panneau Modifier",
"Close the open dialog, then drop the files again": "Fermez la boîte de dialogue ouverte, puis déposez à nouveau les fichiers",
"Close the open dialog to drop files here": "Fermez la boîte de dialogue ouverte pour déposer des fichiers ici",
"Drop to open the face model": "Déposez pour ouvrir le modèle de visages",
"Drop to import": "Déposez pour importer",
"Dropped Files": "Fichiers déposés",
"Import dropped files without review": "Importer les fichiers déposés sans vérification",
"An import is running": "Une importation est en cours",
"An import is running. Drop photos when it has finished": "Une importation est en cours. Déposez les photos quand elle sera terminée",
"Finish Lightroom catalog import before adding photos": "Terminez l'importation du catalogue Lightroom avant d'ajouter des photos",
"The library is empty: the catalog's photos, collections and edits become its first.": "La bibliothèque est vide : les photos, collections et retouches du catalogue seront les premières.",
"The catalog's photos, collections and edits join the ones already in this library.": "Les photos, collections et retouches du catalogue rejoignent celles déjà présentes dans cette bibliothèque.",
"Create a new library": "Créer une nouvelle bibliothèque",
"LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.": "LightCraft ouvre la nouvelle bibliothèque, puis y importe le catalogue. Son dossier doit être nouveau ou vide.",
"Don't ask again (always merge into the open library)": "Ne plus demander (toujours fusionner avec la bibliothèque ouverte)",
"To be asked again: Settings → General → When importing a Lightroom catalog.": "Pour que la question revienne : Réglages → Général → À l’importation d’un catalogue Lightroom.",
"Create Library and Import": "Créer la bibliothèque et importer",
"Another library was opened since this window appeared: nothing was imported": "Une autre bibliothèque a été ouverte depuis l’apparition de cette fenêtre : rien n’a été importé",
"When importing a Lightroom catalog": "À l’importation d’un catalogue Lightroom",
"Ask where to import it": "Demander où l’importer",
"Always merge into the open library": "Toujours fusionner avec la bibliothèque ouverte",
"The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque.",
"At launch": "Au lancement",
"Open the last library": "Ouvrir la dernière bibliothèque",
"Ask me": "Me demander",
"Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.": "« Me demander » affiche vos bibliothèques récentes au lancement. Maintenir Alt (Option sur Mac) pendant que la fenêtre apparaît le demande une fois.",
"Open a Library": "Ouvrir une bibliothèque",
"Choose the library to open.": "Choisissez la bibliothèque à ouvrir.",
"No recent libraries. Open one from a folder, or make a new one.": "Aucune bibliothèque récente. Ouvrez-en une depuis un dossier, ou créez-en une nouvelle.",
"Remove from List": "Retirer de la liste",
"The library itself stays where it is.": "La bibliothèque elle-même reste où elle est.",
"Open": "Ouvrir",
"Open Another…": "Ouvrir une autre…",
"Open now": "Ouverte en ce moment",
"Last opened": "Ouverte le",
"Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté",
"There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft",
"This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque",
"This folder can't be read:": "Ce dossier ne peut pas être lu :",
"The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :",
"Not answering yet: the drive may be asleep or offline": "Pas encore de réponse : le disque est peut-être en veille ou hors ligne",
"This library couldn't be checked": "Impossible de vérifier cette bibliothèque",
"Checking recent libraries": "Vérification des bibliothèques récentes",
"The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte",
"Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages",
"This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :"
}
+9 -1
View File
@@ -297,5 +297,13 @@
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Added {n} photo{}": "{n} foto ditambahkan{:.0}",
"Could not read {name}: {e}": "Tidak dapat membaca {name}: {e}",
"Drop to import {n} item{}": "Jatuhkan untuk mengimpor {n} item{:.0}",
"Drop to import {n} preset{}": "Jatuhkan untuk mengimpor {n} preset{:.0}",
"Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",
"Inside: {names}": "Di dalam: {names}",
"Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",
"Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"
}
+20 -9
View File
@@ -308,7 +308,6 @@
"Clear Filters": "Hapus filter",
"Clear Guides": "Hapus garis panduan",
"Clear History": "Hapus riwayat",
"Clear Preview Cache": "Kosongkan cache pratinjau",
"Clear Print Layout": "Kosongkan tata letak cetak",
"Clear Quick Collection": "Kosongkan koleksi cepat",
"Clear Recent Libraries": "Hapus daftar pustaka terbaru",
@@ -372,7 +371,6 @@
"Company": "Perusahaan",
"Compare": "Bandingkan",
"Compare (Shift+C)": "Bandingkan (Shift+C)",
"Compare Before and After": "Bandingkan Sebelum dan Sesudah",
"Compare needs at least two photos": "Perlu minimal dua foto untuk dibandingkan",
"Component options": "Opsi komponen",
"Compression": "Kompresi",
@@ -1817,19 +1815,13 @@
"Short Edge": "Sisi pendek",
"Show": "Tampilkan",
"Show All": "Tampilkan semua",
"Show All Mask Overlays": "Tampilkan semua overlay masker",
"Show Clipping": "Tampilkan clipping",
"Show Deghost Overlay": "Tampilkan overlay deghost",
"Show Focus Point": "Tampilkan titik fokus",
"Show Hidden Local Locations": "Tampilkan lokasi Lokal yang disembunyikan",
"Show Layout Image": "Tampilkan gambar tata letak",
"Show Luminance Map": "Tampilkan peta luminans",
"Show Mask": "Tampilkan masker",
"Show Mask Overlay": "Tampilkan overlay masker",
"Show Mask Pins": "Tampilkan pin masker",
"Show Original": "Tampilkan yang asli",
"Show Person": "Tampilkan orang",
"Show Photo Counts": "Tampilkan jumlah foto",
"Show Photos with Keyword": "Tampilkan foto dengan kata kunci ini",
"Show Pins": "Tampilkan pin",
"Show Sidebar Section": "Tampilkan bagian bilah samping",
@@ -2325,5 +2317,24 @@
"≤ at most": "≤ maksimal",
"≥ at least": "≥ minimal",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"All Mask Overlays": "Semua overlay masker",
"Before/After Side by Side": "Sebelum/Sesudah berdampingan",
"Clipping": "Clipping",
"Detail View": "Tampilan Detail",
"Edit Panel": "Panel Edit",
"Focus Point": "Titik fokus",
"Layout Image": "Gambar tata letak",
"Mask Overlay": "Overlay masker",
"Mask Pins": "Pin masker",
"Photo Counts": "Jumlah foto",
"An import is running": "Impor sedang berjalan",
"An import is running. Drop photos when it has finished": "Impor sedang berjalan. Jatuhkan foto setelah selesai",
"Close the open dialog to drop files here": "Tutup dialog yang terbuka untuk menjatuhkan file di sini",
"Close the open dialog, then drop the files again": "Tutup dialog yang terbuka, lalu jatuhkan file lagi",
"Drop to import": "Jatuhkan untuk mengimpor",
"Drop to open the face model": "Jatuhkan untuk membuka model wajah",
"Dropped Files": "File yang dijatuhkan",
"Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",
"Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"
}
+9 -1
View File
@@ -283,6 +283,7 @@
"Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?",
"{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}",
"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}",
"Applied {name}": "{name} applicato",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.",
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}"
"Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}",
"Drop to review {n} item{} for import": "Rilascia per rivedere prima dell'importazione (elementi: {n}){:.0}",
"Drop to import {n} item{}": "Rilascia per importare (elementi: {n}){:.0}",
"Drop to import {n} preset{}": "Rilascia per importare predefiniti: {n}{:.0}",
"Could not read {name}: {e}": "Impossibile leggere {name}: {e}",
"Added {n} photo{}": "Foto aggiunte: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})"
}
+20 -9
View File
@@ -215,7 +215,6 @@
"Clear Filters": "Cancella filtri",
"Clear Guides": "Cancella guide",
"Clear History": "Cancella cronologia",
"Clear Preview Cache": "Svuota cache delle anteprime",
"Clear Quick Collection": "Svuota la raccolta rapida",
"Clear Reference": "Cancella riferimento",
"Clear all": "Cancella tutto",
@@ -266,7 +265,6 @@
"Company": "Azienda",
"Compare": "Confronta",
"Compare (Shift+C)": "Confronta (Maiusc+C)",
"Compare Before and After": "Confronta prima e dopo",
"Compare needs at least two photos": "Per confrontare servono almeno due foto",
"Component options": "Opzioni del componente",
"Compression": "Compressione",
@@ -1229,7 +1227,6 @@
"Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Gli album smart si aggiornano da soli: cambia le loro regole, oppure usa Foto → Elimina foto",
"LightCraft stopped": "LightCraft si è chiuso",
"Show Focus Point": "Mostra punto di messa a fuoco",
"Zoom to Focus Point": "Zoom sul punto di messa a fuoco",
"Focus": "Messa a fuoco",
"Focus point": "Punto di messa a fuoco",
@@ -1381,17 +1378,12 @@
"Short Edge": "Lato corto",
"Show": "Mostra",
"Show All": "Mostra tutto",
"Show Clipping": "Mostra tagli",
"Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi",
"Show Hidden Local Locations": "Mostra le posizioni locali nascoste",
"Show Luminance Map": "Mostra mappa di luminanza",
"Show Mask": "Mostra maschera",
"Show Mask Overlay": "Mostra sovrapposizione maschera",
"Show All Mask Overlays": "Mostra tutte le sovrapposizioni maschera",
"Show all overlays": "Mostra tutte le sovrapposizioni",
"Show Mask Pins": "Mostra i punti delle maschere",
"Show Original": "Mostra originale",
"Show Photo Counts": "Mostra il numero di foto",
"Show Photos with Keyword": "Mostra le foto con la parola chiave",
"Show Pins": "Mostra i punti",
"Show Source": "Mostra origine",
@@ -2345,5 +2337,24 @@
"Loupe Info 2": "Info lente 2",
"Use Defaults": "Usa predefiniti",
"Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2."
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.",
"Before/After Side by Side": "Prima/Dopo affiancati",
"Clipping": "Tagli tonali",
"Mask Overlay": "Sovrapposizione maschera",
"Mask Pins": "Punti delle maschere",
"Photo Counts": "Numero di foto",
"Focus Point": "Punto di messa a fuoco",
"All Mask Overlays": "Tutte le sovrapposizioni maschera",
"Layout Image": "Immagine di layout",
"Detail View": "Vista dettaglio",
"Edit Panel": "Pannello Modifica",
"Close the open dialog, then drop the files again": "Chiudi la finestra di dialogo aperta, poi rilascia di nuovo i file",
"Close the open dialog to drop files here": "Chiudi la finestra di dialogo aperta per rilasciare qui i file",
"Drop to open the face model": "Rilascia per aprire il modello dei volti",
"Drop to import": "Rilascia per importare",
"Dropped Files": "File rilasciati",
"Import dropped files without review": "Importa i file rilasciati senza revisione",
"An import is running": "Un'importazione è in corso",
"An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita",
"Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto"
}
+9 -1
View File
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}",
"Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…",
"Inside: {names}": "この中: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?",
"There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?",
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません",
"Listing folders — folders: {folders}, files: {files}": "フォルダーを一覧中 — フォルダー:{folders}、ファイル:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})",
"Drop to review {n} item{} for import": "ドロップして {n} 件を確認してから読み込み{:.0}",
"Drop to import {n} item{}": "ドロップして {n} 件を読み込み{:.0}",
"Drop to import {n} preset{}": "ドロップして {n} 件のプリセットを読み込み{:.0}",
"Could not read {name}: {e}": "{name} を読み取れませんでした: {e}",
"Added {n} photo{}": "{n}枚を追加しました{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?",
"Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}"
}
+20 -9
View File
@@ -215,7 +215,6 @@
"Clear Filters": "フィルターを解除",
"Clear Guides": "ガイドを消去",
"Clear History": "履歴を消去",
"Clear Preview Cache": "プレビューキャッシュを消去",
"Clear Quick Collection": "クイックコレクションを空にする",
"Clear Reference": "参照写真を解除",
"Clear all": "すべて解除",
@@ -260,7 +259,7 @@
"Company": "企業",
"Compare": "比較",
"Compare (Shift+C)": "比較(⇧C)",
"Compare Before and After": "編集前と編集後を比較",
"Before/After Side by Side": "編集前/編集後の左右表示",
"Compare needs at least two photos": "比較には2枚以上の写真が必要です",
"Component options": "構成要素の設定",
"Compression": "圧縮",
@@ -1289,17 +1288,17 @@
"Short Edge": "短辺",
"Show": "表示",
"Show All": "すべて表示",
"Show Clipping": "白飛び・黒つぶれを表示",
"Clipping": "白飛び・黒つぶれ",
"Show Deghost Overlay": "ゴースト除去範囲を表示",
"Show Hidden Local Locations": "非表示にしたローカルの場所を表示",
"Show Luminance Map": "輝度マップを表示",
"Show Mask": "マスクを表示",
"Show Mask Overlay": "マスクの重ね表示",
"Show All Mask Overlays": "すべてのマスクオーバーレイを表示",
"Mask Overlay": "マスクの重ね表示",
"All Mask Overlays": "すべてのマスクの重ね表示",
"Show all overlays": "すべてのオーバーレイを表示",
"Show Mask Pins": "マスクのピンを表示",
"Mask Pins": "マスクのピン",
"Show Original": "元の写真を表示",
"Show Photo Counts": "写真の枚数を表示",
"Photo Counts": "写真の枚数",
"Show Photos with Keyword": "このキーワードの写真を表示",
"Show Pins": "ピンを表示",
"Show Source": "コピー元を表示",
@@ -2314,7 +2313,7 @@
"Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください",
"LightCraft stopped": "LightCraftが終了しました",
"Show Focus Point": "フォーカスポイントを表示",
"Focus Point": "フォーカスポイント",
"Zoom to Focus Point": "フォーカスポイントにズーム",
"Focus": "フォーカス",
"Focus point": "フォーカスポイント",
@@ -2342,5 +2341,17 @@
"Checking the GPU…": "GPU を確認中…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。",
"Checking the GPU for faces": "顔用の GPU を確認中"
"Checking the GPU for faces": "顔用の GPU を確認中",
"Layout Image": "レイアウト画像",
"Detail View": "詳細表示",
"Edit Panel": "編集パネル",
"Close the open dialog, then drop the files again": "開いているダイアログを閉じてから、もう一度ファイルをドロップしてください",
"Close the open dialog to drop files here": "ファイルをここにドロップするには、開いているダイアログを閉じてください",
"Drop to open the face model": "ドロップして顔モデルを開く",
"Drop to import": "ドロップして読み込み",
"Dropped Files": "ドロップしたファイル",
"Import dropped files without review": "ドロップしたファイルを確認せずに読み込む",
"An import is running": "読み込み中です",
"An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",
"Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください"
}
+9 -1
View File
@@ -268,6 +268,7 @@
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"Inside: {names}": "შიგნით: {names}",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"Saved template {name}": "Saved template {name}",
"Skipped {problem}": "Skipped {problem}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Listing folders — folders: {folders}, files: {files}": "საქაღალდეების ჩამოთვლა — საქაღალდეები: {folders}, ფაილები: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"Drop to review {n} item{} for import": "ჩააგდეთ იმპორტამდე გადასახედად (ელემენტები: {n}){:.0}",
"Drop to import {n} item{}": "ჩააგდეთ იმპორტისთვის (ელემენტები: {n}){:.0}",
"Drop to import {n} preset{}": "ჩააგდეთ პრესეტების იმპორტისთვის: {n}{:.0}",
"Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}",
"Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?",
"Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}"
}
+20 -8
View File
@@ -373,7 +373,6 @@
"Clear Filters": "ფილტრების გასუფთავება",
"Clear Guides": "დამხმარე ხაზების გასუფთავება",
"Clear History": "ისტორიის გასუფთავება",
"Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება",
"Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება",
"Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება",
"Clear Reference": "ეტალონის გასუფთავება",
@@ -429,7 +428,7 @@
"Company": "კომპანია",
"Compare": "შედარება",
"Compare (Shift+C)": "შედარება (Shift+C)",
"Compare Before and After": "მანამდე და შემდეგ შედარება",
"Before/After Side by Side": "მანამდე/შემდეგ გვერდიგვერდ",
"Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ",
"Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება",
"Component options": "კომპონენტის პარამეტრები",
@@ -1902,17 +1901,17 @@
"Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და",
"Show": "ჩვენება",
"Show All": "ყველას ჩვენება",
"Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება",
"Show Clipping": "ჩამოჭრილი ტონების ჩვენება",
"All Mask Overlays": "ნიღბების ყველა ზედდება",
"Clipping": "ჩამოჭრილი ტონები",
"Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება",
"Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება",
"Show Luminance Map": "სიკაშკაშის რუკის ჩვენება",
"Show Mask": "ნიღბის ჩვენება",
"Show Mask Overlay": "ნიღბის ზედდების ჩვენება",
"Show Mask Pins": "ნიღბის მარკერების ჩვენება",
"Mask Overlay": "ნიღბის ზედდება",
"Mask Pins": "ნიღბის მარკერები",
"Show Original": "ორიგინალის ჩვენება",
"Show Person": "ადამიანის ჩვენება",
"Show Photo Counts": "ფოტოების რაოდენობის ჩვენება",
"Photo Counts": "ფოტოების რაოდენობა",
"Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება",
"Show Pins": "მარკერების ჩვენება",
"Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება",
@@ -2719,5 +2718,18 @@
"≥ at least": "≥ არანაკლებ",
"⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"Focus Point": "ფოკუსის წერტილი",
"Layout Image": "განლაგების სურათი",
"Detail View": "დეტალური ხედი",
"Edit Panel": "რედაქტირების პანელი",
"Close the open dialog, then drop the files again": "დახურეთ გახსნილი დიალოგი და ფაილები ხელახლა ჩააგდეთ",
"Close the open dialog to drop files here": "ფაილების აქ ჩასაგდებად დახურეთ გახსნილი დიალოგი",
"Drop to open the face model": "ჩააგდეთ სახეების მოდელის გასახსნელად",
"Drop to import": "ჩააგდეთ იმპორტისთვის",
"Dropped Files": "ჩაგდებული ფაილები",
"Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე",
"An import is running": "იმპორტი მიმდინარეობს",
"An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",
"Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი"
}
+58 -50
View File
@@ -5,17 +5,17 @@
" · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}",
" · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)",
" · {} duplicate{} skipped": " · pominięte duplikaty: {}{:.0}",
" · {} failed: {}": " · {} nieudanych: {}",
" · {} moved": " · {} przeniesionych",
" · {} failed: {}": " · nieudane ({}): {}",
" · {} moved": " · przeniesione: {}",
" · {} not readable": " · {} nie można odczytać",
" · {} original{} left at the source": " · oryginały pozostawione w źródle: {}{:.0}",
" · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych",
" · {} restored from Recently Deleted": " · przywrócone z Ostatnio usuniętych: {}",
" — not carried over: {}{}": " — nie przeniesiono: {}{}",
"+{} more": "+{} więcej",
", {failed} file{} not readable": ", nieczytelne pliki: {failed}{:.0}",
"; {kept} already had a location": "; {kept} miało już przypisaną lokalizację",
"; {outside} outside its time range": "; {outside} poza zakresem dat",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, zatwierdzenia: {}, wiersze: +{} / −{} (Δ {}), zasoby binarne: +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć",
"Add {} Photo{}": "Dodaj zdjęcia: {}{:.0}",
"Add {} Photo{}, restore {}": "Dodaj zdjęcia: {}{:.0}, przywróć: {}",
@@ -29,12 +29,12 @@
"Already in the library: {} photo{} selected in All Photos": "Już w bibliotece — zaznaczone zdjęcia we Wszystkich zdjęciach: {}{:.0}",
"Auto ({})": "Automatycznie ({})",
"Auto Import: added {} photo{}": "Automatyczny import — dodane zdjęcia: {}{:.0}",
"Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć",
"Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany obejmują zdjęcia ({n})",
"Automatic ({} MB)": "Automatycznie ({} MB)",
"Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})",
"Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})",
"Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})",
"Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…",
"Building previews for {} photos…": "Tworzenie podglądów (zdjęcia: {})…",
"Click {}:1": "Kliknij {}:1",
"Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}",
"Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych",
@@ -42,7 +42,7 @@
"Decoder: {reason}": "Dekoder: {reason}",
"Delete \"{label}\"": "Usuń „{label}”",
"Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)",
"Delete {n} Photos": "Usuń {n} zdjęć",
"Delete {n} Photos": "Usuń zdjęcia ({n})",
"Delete “{current}”": "Usuń „{current}”",
"Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})",
"Done ({what}): {done} photo{plural}": "Gotowe ({what}) — zdjęcia: {done}{plural:.0}",
@@ -69,10 +69,10 @@
"Label Set: {name}": "Zestaw etykiet: {name}",
"LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących",
"Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć",
"Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom — zdjęcia: {photos}, kolekcje: {collections}, brakujące: {missing}",
"Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony — dodane zdjęcia: {imported}",
"Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony — dodane zdjęcia: {imported}, błędy: {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony — dodane zdjęcia: {imported}; ostrzeżenie indeksu: {warning}",
"Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.",
@@ -89,7 +89,7 @@
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Kopia próbna ({})",
"Proof Preview · {}": "Podgląd próbny · {}",
"Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć",
"Quick Develop · {n} photos": "Szybka obróbka · zdjęcia: {n}",
"Quick Develop: {name}": "Szybka obróbka: {name}",
"Rated {}": "Ocena: {}",
"Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}",
@@ -100,7 +100,7 @@
"Remove “{}” from Favorites": "Usuń „{}” z ulubionych",
"Removed from {}": "Usunięto z {}",
"Removed {what} from {name}": "Usunięto {what} z {name}",
"Rename {n} Photos…": "Zmień nazwy {n} zdjęć…",
"Rename {n} Photos…": "Zmień nazwy zdjęć ({n})…",
"Rename “{name}”": "Zmień nazwę „{name}”",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na zdjęciach ({n}){:.0} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.",
"Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}",
@@ -122,7 +122,7 @@
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.",
"Version: {}": "Wersja: {}",
"Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}",
"Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…",
"Working on {what} for {} photos…": "Przetwarzanie: {what} (zdjęcia: {})…",
"none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}",
"the {n} selected photos": "{n} zaznaczonych zdjęć",
"the {} photos in view": "{} zdjęć w bieżącym widoku",
@@ -132,12 +132,12 @@
"{label} Grading": "Gradacja: {label}",
"{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu",
"{label} {index} of {n}": "{label} {index} z {n}",
"{label}, {n} photos": "{label}, {n} zdjęć",
"{label}, {n} photos": "{label}, zdjęcia: {n}",
"{matching} of {t} photos": "{matching} z {t} zdjęć",
"{matching} photos": "{matching} pasujących zdjęć",
"{matching} photos": "Pasujące zdjęcia: {matching}",
"{mode} spot {} of {}": "{mode}: plamka {} z {}",
"{month}": "{month}",
"{month} {year}": "{month} {year}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}.{year}",
"{msg} ({failed} failed)": "{msg} (nieudanych: {failed})",
"{name} Label": "Etykieta {name}",
"{name} Off": "{name}: wyłączone",
@@ -148,8 +148,8 @@
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){0:.0}: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu teraz oznacza ich utratę.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){:.0}: {e} — LightCraft ponowi próbę",
"{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}",
"{n} photos": "{n} zdjęć",
"{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry",
"{n} photos": "zdjęcia ({n})",
"{n} photos in {} are hidden by the filters above": "Zdjęcia w {} ukryte przez powyższe filtry: {n}",
"{n} photo{}": "Zdjęcia: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "Pasujące zdjęcia: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć",
"{n} photo{} now · updates automatically as photos change": "Zdjęcia teraz: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć",
@@ -158,25 +158,25 @@
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} (zdjęcia: {n}){:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}",
"{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}",
"{what} merge added": "Dodano po scaleniu: {what}",
"{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}",
"{} and higher": "{} i nowsze",
"{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń",
"{} failed": "{} nieudanych",
"{} files": "{} plików",
"{} contributors · {} commits": "Współtwórcy: {} · zatwierdzenia: {}",
"{} failed": "{}: niepowodzenie",
"{} files": "Pliki: {}",
"{} files and {f} folder{}": "Pliki: {}, foldery: {f}{:.0}",
"{} item{}": "Elementy: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.",
"{} photos": "{} zdjęć",
"{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°",
"{} photos": "Zdjęcia: {}",
"{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}",
"{} sample{}": "Próbki: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} plamek na tym zdjęciu",
"{} spot(s) on this photo": "Plamki na tym zdjęciu: {}",
"{} — show their photos": "{} — pokaż ich zdjęcia",
"· {dups} already in the library": "· {dups} już w bibliotece",
"· {n} photo{}": "· zdjęcia: {n}{:.0}",
@@ -184,7 +184,7 @@
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.",
"Filtered: {label}": "Filtrowanie: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Podobne zdjęcia: {n}{:.0} · Widok → Wyczyść filtry, aby zobaczyć wszystkie",
"Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć",
"Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązane zdjęcia: {}",
"{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)",
", {} star{}": ", gwiazdki: {}{:.0}",
", {} label": ", {} etykieta",
@@ -199,7 +199,7 @@
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"{n} {noun}": "{noun}: {n}",
"Trained on: {provenance}": "Wytrenowano na: {provenance}",
" · {queued} waiting": " · {queued} oczekuje",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.",
@@ -252,50 +252,58 @@
" · kept, not applied: {}": " · zachowane, niezastosowane: {}",
" — kept but not applied: {}": " — zachowane, ale niezastosowane: {}",
"Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach",
"Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?",
"Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?",
"Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.",
"Delete the effect preset “{name}”?": "Usunąć ustawienie efektu „{name}”?",
"Delete these {n} effect presets?": "Usunąć zaznaczone ustawienia efektów ({n})?",
"Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia efektów ({n})?{:.0} Tej operacji nie można cofnąć.",
"Effect: {name}": "Efekt: {name}",
"Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}",
"Exported {} effect preset{}": "Wyeksportowane ustawienia efektów: {}{:.0}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Zaimportowane efekty masek: {e}{:.0} (Maskowanie → Efekt)",
"Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami",
"{n} preset{}": "Ustawienia wstępne: {n}{:.0}",
"Update “{name}” with Current Settings": "Zaktualizuj „{name}” bieżącymi ustawieniami",
"{n} preset{}": "Ustawienia: {n}{:.0}",
"{count} more warnings.": "Pozostałe ostrzeżenia: {count}.",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.",
"{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.",
"Applied {name}": "Zastosowano: {name}",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia Znajdź brakujące zdjęcia, aby je ponownie powiązać.",
"Applied {name}": "Zastosowano {name}",
"Delete “{name}”?": "Usunąć „{name}”?",
"Updated {name}": "Zaktualizowano: {name}",
"Updated {name}": "Zaktualizowano {name}",
"Exported to {path}": "Wyeksportowano do {path}",
"Saved template {name}": "Zapisano szablon {name}",
"Imported {name}": "Zaimportowano: {name}",
"Imported {name}": "Zaimportowano {name}",
"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} pages": "Strony: {n}",
"{n} JPEG files": "Pliki JPEG: {n}",
"{s} s of AV1 video": "{s} s wideo AV1",
"{s} s of AV1 video": "Wideo AV1: {s} s",
"Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}",
" · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}",
"{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.",
"{n} raws can now be developed from their raw data": "Pliki RAW, które można już wywołać z danych surowych: {n}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików XMP dla zdjęć ({n}) — czy ich folder jest niedostępny? LightCraft ponawia próby.",
"Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”",
"new, inside {parent}": "nowe, wewnątrz {parent}",
"new, inside {parent}": "nowe, w {parent}",
"Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” ze zdjęć ({n}). Edytuj → Cofnij przywraca.",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {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 połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})",
"Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (twarze: {n}).{:.0} Pliki nie są zmieniane.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij rozdzieli je z powrotem.",
"{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}",
"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}?",
"Listing folders — folders: {folders}, files: {files}": "Przeglądanie folderów — foldery: {folders}, pliki: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je.",
"Drop to review {n} item{} for import": "Upuść, aby przejrzeć przed importem (elementy: {n}){:.0}",
"Drop to import {n} item{}": "Upuść, aby zaimportować (elementy: {n}){:.0}",
"Drop to import {n} preset{}": "Upuść, aby zaimportować ustawienia wstępne: {n}{:.0}",
"Could not read {name}: {e}": "Nie można odczytać {name}: {e}",
"Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}",
"Inside: {names}": "Wewnątrz: {names}",
"Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}"
}
+26 -15
View File
@@ -210,7 +210,6 @@
"Clear Filters": "Wyczyść filtry",
"Clear Guides": "Wyczyść prowadnice",
"Clear History": "Wyczyść historię",
"Clear Preview Cache": "Wyczyść pamięć podręczną podglądów",
"Clear Quick Collection": "Wyczyść szybką kolekcję",
"Clear Reference": "Wyczyść zdjęcie referencyjne",
"Clear all": "Wyczyść wszystko",
@@ -260,7 +259,6 @@
"Company": "Firma",
"Compare": "Porównaj",
"Compare (Shift+C)": "Porównaj (Shift+C)",
"Compare Before and After": "Porównaj przed i po",
"Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć",
"Component options": "Opcje składnika",
"Compression": "Kompresja",
@@ -1319,15 +1317,11 @@
"Short Edge": "Krótsza krawędź",
"Show": "Pokaż",
"Show All": "Pokaż wszystko",
"Show Clipping": "Pokaż prześwietlenia i niedoświetlenia",
"Show Deghost Overlay": "Pokaż nakładkę usuwania duchów",
"Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne",
"Show Luminance Map": "Pokaż mapę luminancji",
"Show Mask": "Pokaż maskę",
"Show Mask Overlay": "Pokaż nakładkę maski",
"Show Mask Pins": "Pokaż znaczniki masek",
"Show Original": "Pokaż oryginał",
"Show Photo Counts": "Pokaż liczbę zdjęć",
"Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym",
"Show Pins": "Pokaż znaczniki",
"Show Source": "Pokaż źródło",
@@ -1711,17 +1705,17 @@
"Use": "Użyj",
"Waiting to start…": "Oczekiwanie na uruchomienie…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.",
"cached picture": "obraz w pamięci podręcznej",
"cached pictures": "obrazy w pamięci podręcznej",
"cached picture": "Obrazy w pamięci podręcznej",
"cached pictures": "Obrazy w pamięci podręcznej",
"commercial use allowed": "dozwolony użytek komercyjny",
"in use": "w użyciu",
"installed": "zainstalowano",
"makes AI Denoise available": "włącza odszumianie AI",
"non-commercial use only": "wyłącznie do użytku niekomercyjnego",
"photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.",
"photo waiting": "zdjęcie oczekuje",
"photos have their denoised picture.": "zdjęcia mają już odszumione wersje.",
"photos waiting": "zdjęć oczekuje",
"photo has its denoised picture.": "Zdjęcia z odszumioną wersją",
"photo waiting": "Oczekujące zdjęcia",
"photos have their denoised picture.": "Zdjęcia z odszumioną wersją",
"photos waiting": "Oczekujące zdjęcia",
"starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI",
"terms unclear": "niejasne warunki",
"the graphics card": "karta graficzna",
@@ -2138,7 +2132,6 @@
"Remove from favorites": "Usuń z ulubionych",
"Reorder Album": "Zmień kolejność w albumie",
"Reset Sidebar Sections": "Przywróć sekcje paska bocznego",
"Show All Mask Overlays": "Pokaż wszystkie nakładki masek",
"Show Person": "Pokaż osobę",
"Show Sidebar Section": "Pokaż sekcję paska bocznego",
"Show all overlays": "Pokaż wszystkie nakładki",
@@ -2236,7 +2229,6 @@
"Background Tasks": "Zadania w tle",
"Cancel Background Task": "Anuluj zadanie w tle",
"Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie",
"Show Focus Point": "Pokaż punkt ostrości",
"Zoom to Focus Point": "Powiększ do punktu ostrości",
"Show in Compare": "Pokaż w porównaniu",
"Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce",
@@ -2269,5 +2261,24 @@
"SDR Shadows": "Cienie SDR",
"SDR Whites": "Biele SDR",
"SDR Clarity": "Przejrzystość SDR",
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU"
"Face Models on the GPU": "Modele twarzy przetwarzane na GPU",
"Before/After Side by Side": "Przed/Po obok siebie",
"Clipping": "Prześwietlenia i niedoświetlenia",
"Mask Overlay": "Nakładka maski",
"Mask Pins": "Znaczniki masek",
"Photo Counts": "Liczba zdjęć",
"All Mask Overlays": "Wszystkie nakładki masek",
"Focus Point": "Punkt ostrości",
"Layout Image": "Obraz układu",
"Detail View": "Widok szczegółów",
"Edit Panel": "Panel Edytuj",
"Close the open dialog, then drop the files again": "Zamknij otwarte okno dialogowe, a potem upuść pliki ponownie",
"Close the open dialog to drop files here": "Zamknij otwarte okno dialogowe, aby upuścić tu pliki",
"Drop to open the face model": "Upuść, aby otworzyć model twarzy",
"Drop to import": "Upuść, aby zaimportować",
"Dropped Files": "Upuszczone pliki",
"Import dropped files without review": "Importuj upuszczone pliki bez przeglądu",
"An import is running": "Import jest w toku",
"An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy",
"Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia"
}
+9 -1
View File
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.",
"Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…",
"Inside: {names}": "Dentro: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?",
"There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?",
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem",
"Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})",
"Drop to review {n} item{} for import": "Solte para revisar antes de importar (itens: {n}){:.0}",
"Drop to import {n} item{}": "Solte para importar (itens: {n}){:.0}",
"Drop to import {n} preset{}": "Solte para importar predefinições: {n}{:.0}",
"Could not read {name}: {e}": "Não foi possível ler {name}: {e}",
"Added {n} photo{}": "{n} foto{0} adicionada{0}",
"Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?",
"Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})"
}
+20 -9
View File
@@ -221,7 +221,6 @@
"Clear Filters": "Limpar filtros",
"Clear Guides": "Limpar guias",
"Clear History": "Limpar histórico",
"Clear Preview Cache": "Limpar cache de prévias",
"Clear Quick Collection": "Limpar coleção rápida",
"Clear Reference": "Limpar referência",
"Clear all": "Limpar tudo",
@@ -272,7 +271,7 @@
"Company": "Empresa",
"Compare": "Comparar",
"Compare (Shift+C)": "Comparar (Shift+C)",
"Compare Before and After": "Comparar antes e depois",
"Before/After Side by Side": "Antes/Depois lado a lado",
"Compare needs at least two photos": "A comparação requer pelo menos duas fotos",
"Component options": "Opções do componente",
"Compression": "Compressão",
@@ -1366,17 +1365,17 @@
"Short Edge": "Aresta curta",
"Show": "Mostrar",
"Show All": "Mostrar tudo",
"Show Clipping": "Mostrar recorte de tons",
"Clipping": "Recorte de tons",
"Show Deghost Overlay": "Mostrar sobreposição de remoção de fantasma",
"Show Hidden Local Locations": "Mostrar locais ocultos de Local",
"Show Luminance Map": "Mostrar mapa de luminância",
"Show Mask": "Mostrar máscara",
"Show Mask Overlay": "Mostrar sobreposição da máscara",
"Show All Mask Overlays": "Mostrar todas as sobreposições de máscara",
"Mask Overlay": "Sobreposição da máscara",
"All Mask Overlays": "Todas as sobreposições de máscara",
"Show all overlays": "Mostrar todas as sobreposições",
"Show Mask Pins": "Mostrar pinos da máscara",
"Mask Pins": "Pinos da máscara",
"Show Original": "Mostrar original",
"Show Photo Counts": "Mostrar contagem de fotos",
"Photo Counts": "Contagem de fotos",
"Show Photos with Keyword": "Mostrar fotos com a palavra-chave",
"Show Pins": "Mostrar pinos",
"Show Source": "Mostrar origem",
@@ -2154,7 +2153,7 @@
"Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto → Excluir foto",
"LightCraft stopped": "O LightCraft parou",
"Show Focus Point": "Mostrar ponto de foco",
"Focus Point": "Ponto de foco",
"Zoom to Focus Point": "Zoom no ponto de foco",
"Focus": "Foco",
"Focus point": "Ponto de foco",
@@ -2188,5 +2187,17 @@
"Color Overlay on B&W": "Sobreposição de cor em P&B",
"Image on Black": "Imagem sobre preto",
"Image on White": "Imagem sobre branco",
"White on Black": "Branco sobre preto"
"White on Black": "Branco sobre preto",
"Layout Image": "Imagem de layout",
"Detail View": "Exibição de detalhe",
"Edit Panel": "Painel Editar",
"Close the open dialog, then drop the files again": "Feche a caixa de diálogo aberta e solte os arquivos novamente",
"Close the open dialog to drop files here": "Feche a caixa de diálogo aberta para soltar arquivos aqui",
"Drop to open the face model": "Solte para abrir o modelo de rostos",
"Drop to import": "Solte para importar",
"Dropped Files": "Arquivos soltos",
"Import dropped files without review": "Importar arquivos soltos sem revisão",
"An import is running": "Uma importação está em andamento",
"An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar",
"Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos"
}
+9 -1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.",
"Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…",
"Inside: {names}": "Внутри: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?",
"There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?",
"Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.",
"Drop to review {n} item{} for import": "Отпустите для проверки перед импортом (элементов: {n}){:.0}",
"Drop to import {n} item{}": "Отпустите для импорта (элементов: {n}){:.0}",
"Drop to import {n} preset{}": "Отпустите для импорта пресетов: {n}{:.0}",
"Could not read {name}: {e}": "Не удалось прочитать {name}: {e}",
"Added {n} photo{}": "Добавлено фото: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?",
"Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}"
}
+20 -9
View File
@@ -181,7 +181,6 @@
"Clear Filters": "Сбросить фильтры",
"Clear Guides": "Убрать направляющие",
"Clear History": "Очистить историю",
"Clear Preview Cache": "Очистить кэш просмотров",
"Clear Quick Collection": "Очистить быструю коллекцию",
"Clear Reference": "Убрать эталон",
"Clear all": "Очистить всё",
@@ -223,7 +222,7 @@
"Comments panel": "Панель комментариев",
"Compare": "Сравнение",
"Compare (Shift+C)": "Сравнение (Shift+C)",
"Compare Before and After": "Сравнить «до» и «после»",
"Before/After Side by Side": "До/после рядом",
"Compare needs at least two photos": "Для сравнения нужно минимум два фото",
"Component options": "Параметры компонента",
"Compression": "Сжатие",
@@ -1134,17 +1133,17 @@
"Sharpness": "Резкость",
"Short Edge": "Короткая сторона",
"Show All": "Показать все",
"Show Clipping": "Показать отсечение",
"Clipping": "Отсечение",
"Show Deghost Overlay": "Показать наложение удаления артефактов",
"Show Hidden Local Locations": "Показать скрытые места в «Локальном»",
"Show Luminance Map": "Показать карту яркости",
"Show Mask": "Показать маску",
"Show Mask Overlay": "Показать наложение маски",
"Show All Mask Overlays": "Показать все наложения масок",
"Mask Overlay": "Наложение маски",
"All Mask Overlays": "Все наложения масок",
"Show all overlays": "Показать все наложения",
"Show Mask Pins": "Показать булавки масок",
"Mask Pins": "Булавки масок",
"Show Original": "Показать оригинал",
"Show Photo Counts": "Показывать количество фото",
"Photo Counts": "Количество фото",
"Show Photos with Keyword": "Показать фото с ключевым словом",
"Show Pins": "Показать булавки",
"Show Source": "Показать источник",
@@ -2307,7 +2306,7 @@
"Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото → Удалить фото",
"LightCraft stopped": "LightCraft остановился",
"Show Focus Point": "Показать точку фокусировки",
"Focus Point": "Точка фокусировки",
"Zoom to Focus Point": "Увеличить по точке фокусировки",
"Focus": "Фокус",
"Focus point": "Точка фокусировки",
@@ -2335,5 +2334,17 @@
"Checking the GPU…": "Проверка ГП…",
"CPU": "ЦП",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.",
"Checking the GPU for faces": "Проверка ГП для лиц"
"Checking the GPU for faces": "Проверка ГП для лиц",
"Layout Image": "Изображение макета",
"Detail View": "Режим детализации",
"Edit Panel": "Панель «Правка»",
"Close the open dialog, then drop the files again": "Закройте открытое окно и перетащите файлы ещё раз",
"Close the open dialog to drop files here": "Закройте открытое окно, чтобы перетащить сюда файлы",
"Drop to open the face model": "Отпустите, чтобы открыть модель лиц",
"Drop to import": "Отпустите для импорта",
"Dropped Files": "Перетащенные файлы",
"Import dropped files without review": "Импортировать перетащенные файлы без проверки",
"An import is running": "Импорт уже выполняется",
"An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится",
"Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото"
}
+9 -1
View File
@@ -297,5 +297,13 @@
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Added {n} photo{}": "{n} foto tillagda{:.0}",
"Could not read {name}: {e}": "Kunde inte läsa {name}: {e}",
"Drop to import {n} item{}": "Släpp för att importera {n} objekt{:.0}",
"Drop to import {n} preset{}": "Släpp för att importera {n} förinställningar{:.0}",
"Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}",
"Inside: {names}": "Inuti: {names}",
"Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",
"Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"
}
+20 -8
View File
@@ -350,7 +350,6 @@
"Clear form": "Rensa formulär",
"Clear Guides": "Rensa stödlinjer",
"Clear History": "Rensa historiken",
"Clear Preview Cache": "Rensa förhandsgranskningscache",
"Clear Quick Collection": "Rensa snabbsamling",
"Clear rating": "Rensa betyg",
"Clear rating and advance": "Rensa betyg och gå vidare",
@@ -416,7 +415,6 @@
"Company": "Företag",
"Compare": "Jämför",
"Compare (Shift+C)": "Jämför (Shift+C)",
"Compare Before and After": "Jämför före och efter",
"Compare needs at least two photos": "Jämförelse kräver minst två foton",
"Component options": "Komponentalternativ",
"Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimerade Nikon-NEF (förlustfritt och förstörande komprimerade, 12 och 14 bitar – t.ex. D3200, D5100, D7000, D750, D850, Z 50)",
@@ -1975,9 +1973,7 @@
"Show": "Visa",
"Show / hide": "Visa/dölj",
"Show All": "Visa alla",
"Show All Mask Overlays": "Visa alla masköverlägg",
"Show all overlays": "Visa alla överlägg",
"Show Clipping": "Visa klippning",
"Show commands without a shortcut": "Visa kommandon utan kortkommando",
"Show Deghost Overlay": "Visa överlägg för spökborttagning",
"Show destination gamut warning": "Visa färgomfångsvarning för målet",
@@ -1996,13 +1992,10 @@
"Show Luminance Map": "Visa luminanskarta",
"Show Mask": "Visa mask",
"Show mask": "Visa mask",
"Show Mask Overlay": "Visa masköverlägg",
"Show Mask Pins": "Visa masknålar",
"Show on Map": "Visa på kartan",
"Show Original": "Visa original",
"Show overlay (O)": "Visa överlägg (O)",
"Show Person": "Visa person",
"Show Photo Counts": "Visa antal foton",
"Show photos": "Visa foton",
"Show Photos with Keyword": "Visa foton med nyckelordet",
"Show Pins": "Visa nålar",
@@ -2414,5 +2407,24 @@
"⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-dra för att räta upp, ⇧G guidad upprätning, ett rutnät under transformering.",
"★ Favorites": "★ Favoriter",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"All Mask Overlays": "Alla masköverlägg",
"Before/After Side by Side": "Före/efter sida vid sida",
"Clipping": "Klippning",
"Detail View": "Detaljvy",
"Edit Panel": "Redigeringspanel",
"Focus Point": "Fokuspunkt",
"Layout Image": "Layoutbild",
"Mask Overlay": "Masköverlägg",
"Mask Pins": "Masknålar",
"Photo Counts": "Antal foton",
"An import is running": "En import pågår",
"An import is running. Drop photos when it has finished": "En import pågår. Släpp fotona när den är klar",
"Close the open dialog to drop files here": "Stäng den öppna dialogrutan för att släppa filer här",
"Close the open dialog, then drop the files again": "Stäng den öppna dialogrutan och släpp sedan filerna igen",
"Drop to import": "Släpp för att importera",
"Drop to open the face model": "Släpp för att öppna ansiktsmodellen",
"Dropped Files": "Släppta filer",
"Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",
"Import dropped files without review": "Importera släppta filer utan granskning"
}
+9 -1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Видалити ключове слово «{name}»?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Його буде знято з фото (кількість: {count}{:.0}) разом із вкладеними ключовими словами. Скасування поверне його.",
"Create Keyword Tag Inside “{name}”…": "Створити ключове слово в «{name}»…",
"Inside: {names}": "Усередині: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "«{parent}» уже містить «{name}». Об’єднати їх?",
"There is a “{name}” at the top level already. Merge the two?": "На верхньому рівні вже є «{name}». Об’єднати їх?",
"Added “{keyword}” to {n} photo{}": "«{keyword}» додано до фото: {n}{:.0}",
@@ -297,5 +298,12 @@
"Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?",
"Listing folders — folders: {folders}, files: {files}": "Перегляд тек — тек: {folders}, файлів: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.",
"Drop to review {n} item{} for import": "Відпустіть для перегляду перед імпортом (елементів: {n}){:.0}",
"Drop to import {n} item{}": "Відпустіть для імпорту (елементів: {n}){:.0}",
"Drop to import {n} preset{}": "Відпустіть для імпорту наборів налаштувань: {n}{:.0}",
"Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}",
"Added {n} photo{}": "Додано фото: {n}{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?",
"Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}"
}
+20 -9
View File
@@ -533,7 +533,6 @@
"Clean Studio": "Чиста студія",
"Clear Cache": "Очистити кеш",
"Clear Filters": "Скинути фільтри",
"Clear Preview Cache": "Очистити кеш переглядів",
"Clear Quick Collection": "Очистити швидку колекцію",
"Clear Reference": "Очистити еталон",
"Clear all": "Очистити все",
@@ -562,7 +561,7 @@
"Command Journal": "Журнал команд",
"Compare": "Порівняти",
"Compare (Shift+C)": "Порівняти (Shift+C)",
"Compare Before and After": "Порівняти до й після",
"Before/After Side by Side": "До/після поруч",
"Compare needs at least two photos": "Для порівняння потрібно щонайменше два фото",
"Component options": "Параметри компонента",
"Concrete Cool": "Холодний бетон",
@@ -1360,16 +1359,16 @@
"Shadows split": "Розділення тіней",
"Shift by": "Зсунути на",
"Short Edge": "Короткий край",
"Show Clipping": "Показати відсікання",
"Clipping": "Відсікання",
"Show Deghost Overlay": "Показати накладання усунення двоїння",
"Show Hidden Local Locations": "Показати приховані локальні розташування",
"Show Luminance Map": "Показати карту яскравості",
"Show Mask Overlay": "Показати накладання маски",
"Show All Mask Overlays": "Показати всі накладення масок",
"Mask Overlay": "Накладання маски",
"All Mask Overlays": "Усі накладання масок",
"Show all overlays": "Показати всі накладення",
"Show Mask Pins": "Показати маркери масок",
"Mask Pins": "Маркери масок",
"Show Original": "Показати оригінал",
"Show Photo Counts": "Показати кількість фото",
"Photo Counts": "Кількість фото",
"Show Photos with Keyword": "Показати фото з ключовим словом",
"Show Pins": "Показати маркери",
"Show Source": "Показати джерело",
@@ -2312,7 +2311,7 @@
"Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото → Видалити фото",
"LightCraft stopped": "LightCraft зупинився",
"Show Focus Point": "Показати точку фокусування",
"Focus Point": "Точка фокусування",
"Zoom to Focus Point": "Збільшити до точки фокусування",
"Focus": "Фокус",
"Focus point": "Точка фокусування",
@@ -2340,5 +2339,17 @@
"Checking the GPU…": "Перевірка ГП…",
"CPU": "ЦП",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.",
"Checking the GPU for faces": "Перевірка ГП для облич"
"Checking the GPU for faces": "Перевірка ГП для облич",
"Layout Image": "Зображення макета",
"Detail View": "Детальне подання",
"Edit Panel": "Панель «Редагування»",
"Close the open dialog, then drop the files again": "Закрийте відкрите вікно та перетягніть файли ще раз",
"Close the open dialog to drop files here": "Закрийте відкрите вікно, щоб перетягнути сюди файли",
"Drop to open the face model": "Відпустіть, щоб відкрити модель облич",
"Drop to import": "Відпустіть для імпорту",
"Dropped Files": "Перетягнуті файли",
"Import dropped files without review": "Імпортувати перетягнуті файли без перегляду",
"An import is running": "Імпорт уже виконується",
"An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться",
"Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото"
}
+9 -1
View File
@@ -102,6 +102,7 @@
"Imported {} photo{}": "Đã nhập {} ảnh{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Đang xếp hàng Khử nhiễu AI (trước bạn: {ahead})…",
"Insert at the cursor (also written {})": "Chèn tại con trỏ (cũng ghi là {})",
"Inside: {names}": "Bên trong: {names}",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Sẽ gỡ khỏi {count} ảnh{:.0}, kèm từ khóa con. Hoàn tác sẽ khôi phục.",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"Label Set: {name}": "Bộ nhãn: {name}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… và {more} nữa",
"…and {more} more": "…and {more} more"
"…and {more} more": "…and {more} more",
"Added {n} photo{}": "Đã thêm {n} ảnh{:.0}",
"Could not read {name}: {e}": "Không thể đọc {name}: {e}",
"Drop to import {n} item{}": "Thả để nhập {n} mục{:.0}",
"Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}",
"Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",
"Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"
}
+20 -8
View File
@@ -288,7 +288,6 @@
"Clear Filters": "Xóa bộ lọc",
"Clear Guides": "Xóa đường dẫn",
"Clear History": "Xóa lịch sử",
"Clear Preview Cache": "Xóa bộ nhớ đệm xem trước",
"Clear Quick Collection": "Xóa Bộ sưu tập nhanh",
"Clear Recent Libraries": "Xóa thư viện gần đây",
"Clear Reference": "Xóa tham chiếu",
@@ -342,7 +341,6 @@
"Company": "Công ty",
"Compare": "So sánh",
"Compare (Shift+C)": "So sánh (Shift+C)",
"Compare Before and After": "So sánh Trước và Sau",
"Compare needs at least two photos": "So sánh cần ít nhất hai ảnh",
"Component options": "Tùy chọn thành phần",
"Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Nikon NEF nén (nén không mất và có mất, 12 và 14 bit — ví dụ D3200, D5100, D7000, D750, D850, Z 50)",
@@ -1676,17 +1674,12 @@
"Short Edge": "Cạnh ngắn",
"Show": "Hiện",
"Show All": "Hiện tất cả",
"Show All Mask Overlays": "Hiện mọi lớp phủ mặt nạ",
"Show Clipping": "Hiện vùng cắt tông",
"Show Deghost Overlay": "Hiện lớp phủ deghost",
"Show Hidden Local Locations": "Hiện vị trí Local đã ẩn",
"Show Luminance Map": "Hiện bản đồ độ sáng",
"Show Mask": "Hiện mặt nạ",
"Show Mask Overlay": "Hiện lớp phủ mặt nạ",
"Show Mask Pins": "Hiện ghim mặt nạ",
"Show Original": "Hiện bản gốc",
"Show Person": "Hiện người",
"Show Photo Counts": "Hiện số lượng ảnh",
"Show Photos with Keyword": "Hiện ảnh có từ khóa",
"Show Pins": "Hiện ghim",
"Show Sidebar Section": "Hiện mục thanh bên",
@@ -2220,5 +2213,24 @@
"≥ at least": "≥ tối thiểu",
"⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-kéo để chỉnh thẳng, ⇧G Chỉnh thẳng có hướng dẫn, lưới khi biến đổi.",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"All Mask Overlays": "Mọi lớp phủ mặt nạ",
"Before/After Side by Side": "Trước/Sau cạnh nhau",
"Clipping": "Vùng cắt tông",
"Detail View": "Chế độ xem chi tiết",
"Edit Panel": "Bảng Chỉnh sửa",
"Focus Point": "Điểm lấy nét",
"Layout Image": "Ảnh bố cục",
"Mask Overlay": "Lớp phủ mặt nạ",
"Mask Pins": "Ghim mặt nạ",
"Photo Counts": "Số lượng ảnh",
"An import is running": "Đang có một lần nhập chạy",
"An import is running. Drop photos when it has finished": "Đang có một lần nhập chạy. Hãy thả ảnh khi nó hoàn tất",
"Close the open dialog to drop files here": "Hãy đóng hộp thoại đang mở để thả tệp vào đây",
"Close the open dialog, then drop the files again": "Hãy đóng hộp thoại đang mở, rồi thả lại các tệp",
"Drop to import": "Thả để nhập",
"Drop to open the face model": "Thả để mở mô hình khuôn mặt",
"Dropped Files": "Tệp đã thả",
"Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",
"Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"
}
+9 -1
View File
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "要删除关键字“{name}”吗?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}",
"Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…",
"Inside: {names}": "其中:{names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?",
"There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?",
"Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存",
"Listing folders — folders: {folders}, files: {files}": "正在列出文件夹 — 文件夹:{folders},文件:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})",
"Drop to review {n} item{} for import": "松开鼠标以在导入前检查 {n} 个项目{:.0}",
"Drop to import {n} item{}": "松开鼠标以导入 {n} 个项目{:.0}",
"Drop to import {n} preset{}": "松开鼠标以导入 {n} 个预设{:.0}",
"Could not read {name}: {e}": "无法读取 {name}:{e}",
"Added {n} photo{}": "已添加 {n} 张照片{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里?",
"Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}"
}
+20 -9
View File
@@ -220,7 +220,6 @@
"Clear Filters": "清除过滤器",
"Clear Guides": "清除参考线",
"Clear History": "清除历史记录",
"Clear Preview Cache": "清除预览缓存",
"Clear Quick Collection": "清空快速集合",
"Clear Reference": "清除参考照片",
"Clear all": "全部清除",
@@ -270,7 +269,6 @@
"Company": "公司",
"Compare": "比较",
"Compare (Shift+C)": "比较(Shift+C)",
"Compare Before and After": "比较修改前和修改后",
"Compare needs at least two photos": "比较至少需要两张照片",
"Component options": "组件选项",
"Compression": "压缩",
@@ -1351,17 +1349,12 @@
"Short Edge": "短边",
"Show": "显示",
"Show All": "显示全部",
"Show Clipping": "显示溢出警告",
"Show Deghost Overlay": "显示去重影叠加",
"Show Hidden Local Locations": "显示隐藏的本地位置",
"Show Luminance Map": "显示明亮度图",
"Show Mask": "显示蒙版",
"Show Mask Overlay": "显示蒙版叠加",
"Show All Mask Overlays": "显示所有蒙版叠加",
"Show all overlays": "显示所有叠加",
"Show Mask Pins": "显示蒙版图钉",
"Show Original": "显示原图",
"Show Photo Counts": "显示照片数量",
"Show Photos with Keyword": "显示包含该关键字的照片",
"Show Pins": "显示图钉",
"Show Source": "显示来源",
@@ -2153,7 +2146,6 @@
"Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 → 删除照片”",
"LightCraft stopped": "LightCraft 已停止",
"Show Focus Point": "显示对焦点",
"Zoom to Focus Point": "缩放至对焦点",
"Focus": "对焦",
"Focus point": "对焦点",
@@ -2186,5 +2178,24 @@
"Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。",
"This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。",
"Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。",
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)"
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)",
"Before/After Side by Side": "修改前/修改后左右排列",
"Clipping": "溢出警告",
"Mask Overlay": "蒙版叠加",
"All Mask Overlays": "所有蒙版叠加",
"Mask Pins": "蒙版图钉",
"Photo Counts": "照片数量",
"Focus Point": "对焦点",
"Layout Image": "版面图像",
"Detail View": "详细视图",
"Edit Panel": "编辑面板",
"Close the open dialog, then drop the files again": "请先关闭打开的对话框,然后重新拖入文件",
"Close the open dialog to drop files here": "请先关闭打开的对话框,才能在此拖入文件",
"Drop to open the face model": "松开鼠标以打开人脸模型",
"Drop to import": "松开鼠标以导入",
"Dropped Files": "拖入的文件",
"Import dropped files without review": "不经检查直接导入拖入的文件",
"An import is running": "正在导入",
"An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片",
"Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入"
}
+9 -1
View File
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}",
"Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…",
"Inside: {names}": "其中:{names}",
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?",
"There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?",
"Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}",
@@ -297,5 +298,12 @@
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體",
"Listing folders — folders: {folders}, files: {files}": "正在列出資料夾 — 資料夾:{folders},檔案:{files}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})",
"Drop to review {n} item{} for import": "放開滑鼠以在匯入前檢查 {n} 個項目{:.0}",
"Drop to import {n} item{}": "放開滑鼠以匯入 {n} 個項目{:.0}",
"Drop to import {n} preset{}": "放開滑鼠以匯入 {n} 個預設集{:.0}",
"Could not read {name}: {e}": "無法讀取 {name}:{e}",
"Added {n} photo{}": "已加入 {n} 張照片{:.0}",
"Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡?",
"Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}"
}
+21 -10
View File
@@ -220,7 +220,6 @@
"Clear Filters": "清除篩選條件",
"Clear Guides": "清除參考線",
"Clear History": "清除歷史紀錄",
"Clear Preview Cache": "清除預覽快取",
"Clear Quick Collection": "清除快速集合",
"Clear Reference": "清除參考照片",
"Clear all": "全部清除",
@@ -270,7 +269,7 @@
"Company": "公司",
"Compare": "比較",
"Compare (Shift+C)": "比較(Shift+C)",
"Compare Before and After": "比較編輯前與編輯後",
"Before/After Side by Side": "編輯前/後左右檢視",
"Compare needs at least two photos": "比較需要至少兩張照片",
"Component options": "元件選項",
"Compression": "壓縮",
@@ -405,7 +404,7 @@
"Desert Heat": "沙漠熱浪",
"Desert Warmth": "沙漠暖色",
"Desktop": "桌面",
"Detail": "詳細檢視",
"Detail": "細節",
"Detail (D)": "詳細檢視(D)",
"Details": "詳細資訊",
"Diagonal": "對角線",
@@ -1351,17 +1350,17 @@
"Short Edge": "短邊",
"Show": "顯示",
"Show All": "全部顯示",
"Show Clipping": "顯示溢色",
"Clipping": "溢色",
"Show Deghost Overlay": "顯示去除鬼影疊加",
"Show Hidden Local Locations": "顯示隱藏的本機位置",
"Show Luminance Map": "顯示明度圖",
"Show Mask": "顯示遮色片",
"Show Mask Overlay": "顯示遮色片疊加",
"Show All Mask Overlays": "顯示所有蒙版疊加",
"Mask Overlay": "遮色片疊加",
"All Mask Overlays": "所有遮色片疊加",
"Show all overlays": "顯示所有疊加",
"Show Mask Pins": "顯示遮色片控制點",
"Mask Pins": "遮色片控制點",
"Show Original": "顯示原始照片",
"Show Photo Counts": "顯示照片數量",
"Photo Counts": "照片數量",
"Show Photos with Keyword": "顯示含此關鍵字的照片",
"Show Pins": "顯示控制點",
"Show Source": "顯示來源",
@@ -2115,7 +2114,7 @@
"Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)",
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 → 刪除照片」",
"LightCraft stopped": "LightCraft 已停止",
"Show Focus Point": "顯示對焦點",
"Focus Point": "對焦點",
"Zoom to Focus Point": "縮放至對焦點",
"Focus": "對焦",
"Focus point": "對焦點",
@@ -2143,5 +2142,17 @@
"Checking the GPU…": "正在檢查 GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。",
"Checking the GPU for faces": "正在檢查用於臉孔的 GPU"
"Checking the GPU for faces": "正在檢查用於臉孔的 GPU",
"Layout Image": "版面圖像",
"Detail View": "詳細檢視",
"Edit Panel": "編輯面板",
"Close the open dialog, then drop the files again": "請先關閉開啟的對話框,然後重新拖放檔案",
"Close the open dialog to drop files here": "請先關閉開啟的對話框,才能在此拖放檔案",
"Drop to open the face model": "放開滑鼠以開啟人臉模型",
"Drop to import": "放開滑鼠以匯入",
"Dropped Files": "拖放的檔案",
"Import dropped files without review": "不經檢查直接匯入拖放的檔案",
"An import is running": "正在匯入",
"An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片",
"Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入"
}
+48 -9
View File
@@ -188,6 +188,7 @@ pub struct Button<'a> {
kind: Kind,
size: Size,
enabled: bool,
busy: bool,
widget: Option<String>,
tooltip: Option<&'a str>,
min_width: f32,
@@ -204,6 +205,7 @@ impl<'a> Button<'a> {
kind: Kind::default(),
size: Size::default(),
enabled: true,
busy: false,
widget: None,
tooltip: None,
min_width: 0.0,
@@ -256,6 +258,14 @@ impl<'a> Button<'a> {
self
}
/// Working on what it was pressed for: a spinner takes the icon's place (or sits before the
/// text, inside the button's own padding, so the button keeps its width), a click does nothing,
/// and screen readers hear it is busy.
pub fn busy(mut self, busy: bool) -> Self {
self.busy = busy;
self
}
/// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on
/// screen, as every registered id is.
pub fn widget(mut self, id: impl Into<String>) -> Self {
@@ -331,14 +341,30 @@ impl<'a> Button<'a> {
};
// a button in a disabled part of the window is disabled too
let enabled = self.enabled && ui.is_enabled();
let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() });
let resp = ui.interact(
rect,
id,
if !enabled {
Sense::hover()
} else if self.busy {
// still reachable by the keyboard, but nothing to press
Sense::focusable_noninteractive()
} else {
Sense::click()
},
);
let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone()));
if self.busy {
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_busy());
}
if let Some(w) = &self.widget {
crate::widgets::register(ui.ctx(), w.clone(), rect);
}
let state = if !enabled {
State::Disabled
} else if self.busy {
State::Idle
} else if resp.is_pointer_button_down_on() {
State::Pressed
} else if resp.hovered() {
@@ -362,16 +388,29 @@ impl<'a> Button<'a> {
}
let galley = self.galley(ui);
let icon_s = self.size.icon();
let content_w = match (&galley, self.icon) {
(Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x,
(Some(g), None) => g.size().x,
(None, Some(_)) => icon_s,
(None, None) => 0.0,
// busy: the spinner is the icon's slot; with text alone it is a smaller one in the button's
// own padding (a little tighter), so the button never grows
let spinner_only = self.busy && self.icon.is_none() && galley.is_some();
let (slot, gap) = if spinner_only { ((self.size.padding() * 2.0 - 12.0).clamp(8.0, icon_s), 4.0) } else { (icon_s, self.size.gap()) };
let has_slot = self.icon.is_some() || spinner_only;
let content_w = match (&galley, has_slot) {
(Some(g), true) => slot + gap + g.size().x,
(Some(g), false) => g.size().x,
(None, true) => slot,
(None, false) => 0.0,
};
let mut x = rect.center().x - content_w / 2.0;
if let Some(icon) = self.icon {
paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content);
x += icon_s + self.size.gap();
if has_slot {
let slot_rect = Rect::from_center_size(pos2(x + slot / 2.0, rect.center().y), vec2(slot, slot));
if self.busy {
if ui.is_rect_visible(rect) {
crate::progress::paint_spinner(p, slot_rect, lk.content, ui.input(|i| i.time));
ui.ctx().request_repaint_after(std::time::Duration::from_millis(33));
}
} else if let Some(icon) = self.icon {
paint(p, slot_rect, icon, lk.content);
}
x += slot + gap;
}
if let Some(g) = galley {
let at = pos2(x, rect.center().y - g.size().y / 2.0);
+19
View File
@@ -296,6 +296,23 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
ctx.request_repaint();
ok(Value::Null)
}
"ui.dropFiles" => {
// every entry must be a string: a script is not told `ok` for a part of what it sent
let Some(paths) =
p.get("paths").and_then(Value::as_array).and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
else {
return err("ui.dropFiles: paths must be an array of strings");
};
if p.get("hover").and_then(Value::as_bool).unwrap_or(false) {
app.drop_hover = paths;
} else {
// the drag ends with the drop, as it does for the OS
app.drop_hover.clear();
app.drop_pending = paths;
}
ctx.request_repaint();
ok(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ctx.request_repaint();
@@ -392,8 +409,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
| crate::state::Dialog::SamModel { .. }
| crate::state::Dialog::SmartRules { .. }
| crate::state::Dialog::SaveEffect { .. }
| crate::state::Dialog::CaptureTime { .. }
))
|| (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d))
|| (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d))
{
app.ui.dialog = Some(d);
}
+99
View File
@@ -0,0 +1,99 @@
//! What dropping the files dragged over the window will do: the same sorting the drop applies,
//! shown on a veil over the whole window while the drag lasts.
use egui::{Align2, Color32, Context, Order, Rect, Stroke, StrokeKind, vec2};
use crate::LightcraftApp;
use crate::theme::Tokens;
/// Dropped paths by what becomes of them.
#[derive(Default, Debug, PartialEq)]
pub struct Sorted {
/// Face models (`.onnx`): the first opens the licence dialog.
pub models: Vec<String>,
/// Preset files: imported as presets.
pub presets: Vec<String>,
/// Everything else: photos and folders, for the import.
pub photos: Vec<String>,
}
/// Sort `paths` as a drop does.
pub fn classify(paths: Vec<String>) -> Sorted {
let (models, rest): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| crate::is_model_file(p));
let (presets, photos) = crate::split_presets(rest);
Sorted { models, presets, photos }
}
/// The lines the card shows for files hovering over the window (`unknown`: how many have no path
/// to look at, as on some platforms and in browsers), for the current settings.
fn lines(app: &LightcraftApp, paths: Vec<String>, unknown: bool) -> Vec<String> {
if app.ui.dialog.is_some() {
return vec![crate::i18n::tr("Close the open dialog to drop files here").to_string()];
}
let drop = classify(paths);
let mut out = Vec::new();
if !drop.models.is_empty() {
out.push(crate::i18n::tr("Drop to open the face model").to_string());
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
// no library open yet (desktop: the web build's drops import into its session as before):
// photos wait for the library chosen, presets are refused (the drop handler does the same)
let waiting = crate::panels::library_problem::holds_drops(app) && !cfg!(target_arch = "wasm32");
if !drop.presets.is_empty() && waiting {
out.push(crate::i18n::tr("Open a library first, then drop the presets again").to_string());
} else if !drop.presets.is_empty() {
let n = drop.presets.len();
out.push(crate::i18n::tr_format!("Drop to import {n} preset{}", plural(n), n = n));
}
if !drop.photos.is_empty() && waiting {
out.push(crate::i18n::tr("The photos are imported once a library is open").to_string());
} else if !drop.photos.is_empty() && app.lightroom.is_some() && !cfg!(target_arch = "wasm32") {
// the drop is refused while a Lightroom catalog import owns the catalog
out.push(crate::i18n::tr("Finish Lightroom catalog import before adding photos").to_string());
} else if !drop.photos.is_empty() && crate::import::running(app) && !cfg!(target_arch = "wasm32") {
out.push(crate::i18n::tr("An import is running. Drop photos when it has finished").to_string());
} else if !drop.photos.is_empty() {
let n = drop.photos.len();
// the web build has no review to open: its drops import at once
if app.ui.settings.import_dropped_directly || cfg!(target_arch = "wasm32") {
out.push(crate::i18n::tr_format!("Drop to import {n} item{}", plural(n), n = n));
} else {
out.push(crate::i18n::tr_format!("Drop to review {n} item{} for import", plural(n), n = n));
}
}
if out.is_empty() || unknown {
out.push(crate::i18n::tr("Drop to import").to_string());
}
out
}
/// Veil and card while files are dragged over the window.
pub fn show(app: &LightcraftApp, ctx: &Context) {
let count = ctx.input(|i| i.raw.hovered_files.len());
if count == 0 {
return;
}
let known: Vec<String> = ctx.input(|i| {
i.raw.hovered_files.iter().filter_map(|f| f.path.as_ref()).map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect()
});
let lines = lines(app, known.clone(), known.len() < count);
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::new("drop-overlay")));
painter.rect_filled(screen, 0.0, Color32::from_black_alpha(150));
painter.rect_stroke(screen.shrink(10.0), 12.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
let galleys: Vec<_> = lines.into_iter().map(|l| painter.layout(l, t.semibold(18.0), t.text, (screen.width() - 120.0).max(200.0))).collect();
let (w, h) = galleys.iter().fold((0.0f32, 0.0f32), |(w, h), g| (w.max(g.size().x), h + g.size().y + 8.0));
let card = Rect::from_center_size(screen.center(), vec2(w + 64.0, h + 40.0));
painter.rect_filled(card, 10.0, t.chrome);
painter.rect_stroke(card, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
let mut y = card.top() + 24.0;
for g in galleys {
let at = egui::pos2(card.center().x, y);
let height = g.size().y;
painter.galley(Align2::CENTER_TOP.anchor_size(at, g.size()).min, g, t.text);
y += height + 8.0;
}
crate::widgets::register(ctx, "dropOverlay", card);
ctx.request_repaint();
}
+312
View File
@@ -0,0 +1,312 @@
//! Help ▸ Debug ▸ Frame Rate (`view.frameRate`; `LIGHTCRAFT_FPS=1` starts with it on): a
//! frame-rate display in the window's top-right corner, to watch how a slider drag or a zoom holds
//! up. It shows where a frame's time goes: the UI
//! thread's CPU time (layout, painting, texture uploads) and the rest of the frame interval, which
//! is waiting — for vsync, or for the GPU when render work is ahead of the window's frame. A
//! tick over the graph marks each frame in which a render arrived, so spikes can be put next to
//! renders. While it shows, the window repaints continuously, so the rate is the one a drag gets.
use std::collections::VecDeque;
use crate::LightcraftApp;
use crate::render::Slot;
/// Frames kept for the graph (about two seconds at 120 Hz).
const KEEP: usize = 240;
/// A gap between frames longer than this is a pause (window hidden or occluded, the machine
/// asleep), not a frame: the display starts counting again instead of showing it as a spike.
const PAUSE: f64 = 2.0;
#[derive(Clone, Copy)]
pub(crate) struct Frame {
at: f64,
/// Seconds since the previous frame.
pub(crate) dt: f64,
/// The previous frame's CPU time on the UI thread, seconds.
cpu: f64,
/// Renders that arrived this frame.
renders: u64,
/// Of those, thrown away (superseded by a newer request).
discarded: u64,
}
/// The display's state, kept in the context's temp data (one per window or headless view) and
/// dropped while the display is off, so turning it back on starts a fresh count.
#[derive(Clone, Default)]
struct Hud {
last: Option<f64>,
completed: u64,
discarded: u64,
frames: VecDeque<Frame>,
/// When the numbers were last logged.
logged: Option<f64>,
}
fn hud_id() -> egui::Id {
egui::Id::new("fps-hud-state")
}
/// The display is off: drop its state, so the time it was off never shows as one long frame.
pub(crate) fn forget(ctx: &egui::Context) {
ctx.data_mut(|d| d.remove::<Hud>(hud_id()));
}
/// Record a frame at `now` (`completed`/`discarded`: the renderer's running totals; `cpu`: the
/// previous frame's UI CPU time, seconds). Returns the frames kept for the display, and whether
/// the numbers are due in the log (once a second; A/B runs read them).
fn record(ctx: &egui::Context, now: f64, completed: u64, discarded: u64, cpu: f64) -> (Vec<Frame>, bool) {
ctx.data_mut(|d| {
let hud = d.get_temp_mut_or_default::<Hud>(hud_id());
match hud.last {
Some(prev) if now - prev > PAUSE || now < prev => hud.frames.clear(),
Some(prev) => {
let renders = completed.saturating_sub(hud.completed);
let thrown = discarded.saturating_sub(hud.discarded);
hud.frames.push_back(Frame { at: now, dt: (now - prev).max(1e-6), cpu, renders, discarded: thrown });
while hud.frames.len() > KEEP {
hud.frames.pop_front();
}
}
None => {}
}
hud.last = Some(now);
hud.completed = completed;
hud.discarded = discarded;
let log = hud.logged.is_none_or(|t| now - t >= 1.0 || now < t);
if log {
hud.logged = Some(now);
}
(hud.frames.iter().copied().collect(), log)
})
}
/// The frames the display holds now (tests).
#[cfg(test)]
pub(crate) fn frames(ctx: &egui::Context) -> Vec<Frame> {
ctx.data_mut(|d| d.get_temp::<Hud>(hud_id())).map(|h| h.frames.into_iter().collect()).unwrap_or_default()
}
fn mb(bytes: u64) -> f64 {
bytes as f64 / (1 << 20) as f64
}
/// Count this frame and draw the display over everything.
pub fn show(ctx: &egui::Context, app: &LightcraftApp) {
let now = ctx.input(|i| i.time);
let cpu = app.perf.host_frame_ms.unwrap_or(0.0) / 1e3;
let (frames, log) = record(ctx, now, app.renderer.completed, app.renderer.discarded, cpu);
let recent: Vec<&Frame> = frames.iter().filter(|f| now - f.at <= 1.0).collect();
let fps_now = frames.last().map_or(0.0, |f| 1.0 / f.dt);
let worst = recent.iter().copied().max_by(|a, b| a.dt.total_cmp(&b.dt));
let avg_fps = if recent.is_empty() { 0.0 } else { recent.len() as f64 / recent.iter().map(|f| f.dt).sum::<f64>().max(1e-6) };
let slow = recent.iter().filter(|f| f.dt > 1.0 / 50.0).count();
let renders_s: u64 = recent.iter().map(|f| f.renders).sum();
let discarded_s: u64 = recent.iter().map(|f| f.discarded).sum();
let window = app.renderer.textures.get(&Slot::Region);
let mem = lightcraft_engine::gpu::memory();
// (`ready` first: `available` would wait for a device still being created)
let device = match lightcraft_engine::gpu::unavailable_reason() {
Some(why) => format!("off ({why})"),
None if lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available() => {
lightcraft_engine::gpu::adapter_name().unwrap_or_else(|| "on".into())
}
None => "starting…".into(),
};
if log {
let (wdt, wcpu) = worst.map_or((0.0, 0.0), |w| (w.dt * 1e3, w.cpu * 1e3));
let p95 = {
let mut d: Vec<f64> = recent.iter().map(|f| f.dt * 1e3).collect();
d.sort_by(f64::total_cmp);
d.get(d.len().saturating_sub(1) * 95 / 100).copied().unwrap_or(0.0)
};
log::info!(
"fps-hud: frames {} slow>20ms {slow} p95 {p95:.1} worst {wdt:.1} (cpu {wcpu:.1}) renders {renders_s} discarded {discarded_s} window {:.1} gpu {device}",
recent.len(),
window.map_or(0.0, |t| t.ms)
);
}
let lines = [
format!("{fps_now:>4.0} fps avg {avg_fps:.0} {slow} frames > 20 ms in 1 s"),
match worst {
Some(w) => format!("worst {:.1} ms = CPU {:.1} + wait {:.1}", w.dt * 1e3, w.cpu * 1e3, (w.dt - w.cpu).max(0.0) * 1e3),
None => "worst –".to_string(),
},
format!("UI CPU {:.1} ms (update {:.1})", app.perf.host_frame_ms.unwrap_or(0.0), app.perf.update_ms),
format!("render frame {:.1} ms", app.renderer.last_main_ms),
match window {
Some(t) => format!("render window {:.1} ms", t.ms),
None => "render window –".to_string(),
},
format!("renders: {} queued, {} running, {renders_s}/s ({discarded_s} thrown away)", app.renderer.queued(), app.renderer.in_flight()),
format!("GPU: {device}"),
format!("GPU mem {:.0} MB (+{:.0} pooled)", mb(mem.allocated), mb(mem.pooled)),
];
let screen = ctx.content_rect();
let width = 300.0;
egui::Area::new(egui::Id::new("fps-hud"))
.order(egui::Order::Tooltip)
.fixed_pos(egui::pos2(screen.right() - width - 24.0, screen.top() + 56.0))
.interactable(false)
.show(ctx, |ui| {
egui::Frame::new().fill(egui::Color32::from_black_alpha(210)).corner_radius(6.0).inner_margin(8.0).show(ui, |ui| {
ui.set_width(width);
ui.spacing_mut().item_spacing.y = 1.0;
for l in &lines {
ui.label(egui::RichText::new(l).monospace().size(11.0).color(egui::Color32::from_gray(230)));
}
ui.add_space(4.0);
graph(ui, &frames, width);
ui.label(
egui::RichText::new("bars: blue = UI CPU, orange/red = waiting · ticks = a render arrived · line = 60 fps")
.size(9.0)
.color(egui::Color32::from_gray(160)),
);
});
});
// a rate the drag gets, not the idle one
ctx.request_repaint();
}
/// Frame intervals, 0 at the bottom and 50 ms at the top: the UI thread's CPU part in blue and
/// the waiting part above it (green within 60 fps, orange within 30, red beyond), with a tick
/// over each frame in which a render arrived.
fn graph(ui: &mut egui::Ui, frames: &[Frame], width: f32) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 70.0), egui::Sense::hover());
let p = ui.painter_at(rect);
let plot = egui::Rect::from_min_max(egui::pos2(rect.left(), rect.top() + 6.0), rect.max);
let y = |ms: f64| plot.bottom() - (ms / 50.0).clamp(0.0, 1.0) as f32 * plot.height();
let w = rect.width() / KEEP as f32;
let start = KEEP.saturating_sub(frames.len());
for (i, f) in frames.iter().enumerate() {
let (ms, cpu) = (f.dt * 1e3, (f.cpu * 1e3).min(f.dt * 1e3));
let x = rect.left() + (start + i) as f32 * w;
let bar = |from: f64, to: f64, c: egui::Color32| {
p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, y(to)), egui::pos2(x + w.max(1.0), y(from))), 0.0, c);
};
let wait = if ms > 33.4 {
egui::Color32::from_rgb(230, 80, 70)
} else if ms > 17.5 {
egui::Color32::from_rgb(235, 160, 60)
} else {
egui::Color32::from_rgb(110, 190, 110)
};
bar(cpu, ms, wait);
bar(0.0, cpu, egui::Color32::from_rgb(90, 150, 240));
if f.renders > 0 {
p.rect_filled(
egui::Rect::from_min_max(egui::pos2(x, rect.top()), egui::pos2(x + w.max(1.0), rect.top() + 4.0)),
0.0,
egui::Color32::WHITE,
);
}
}
p.hline(rect.x_range(), y(1000.0 / 60.0), egui::Stroke::new(1.0, egui::Color32::from_rgb(90, 200, 90)));
p.hline(rect.x_range(), y(1000.0 / 30.0), egui::Stroke::new(1.0, egui::Color32::from_rgb(200, 90, 80).gamma_multiply(0.6)));
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::menubar::{MenuNode, menu_bar};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
fn set(h: &mut Headless, params: serde_json::Value) -> Option<bool> {
let r = h.request("engine.execute", json!({"command": "view.frameRate", "params": params}), T);
assert_eq!(r["ok"], true, "{r}");
r["result"]["show"].as_bool()
}
/// The Help ▸ Debug item for it, and its checkmark.
fn debug_item(app: &LightcraftApp) -> Option<Option<bool>> {
let help = menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default();
help.into_iter().find_map(|n| match n {
MenuNode::Submenu { label, children } if label == "Debug" => children.into_iter().find_map(|c| match c {
MenuNode::Item { id, checked, .. } if id == "view.frameRate" => Some(checked),
_ => None,
}),
_ => None,
})
}
/// Help ▸ Debug ▸ Frame Rate toggles the display (a checked menu item), `{show}` sets it,
/// and frames draw with it on.
#[test]
fn show_frame_rate_is_a_debug_toggle() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
let was = h.app.ui.frame_rate;
assert_eq!(debug_item(&h.app), Some(Some(was)), "Help ▸ Debug lists it, checked as it is");
assert_eq!(set(&mut h, json!({})), Some(!was));
assert_eq!(debug_item(&h.app), Some(Some(!was)));
// frames with the display on draw (and keep the window repainting)
assert_eq!(set(&mut h, json!({"show": true})), Some(true));
for _ in 0..3 {
h.step();
}
assert_eq!(set(&mut h, json!({"show": false})), Some(false));
assert!(!h.app.ui.frame_rate);
}
fn worst_ms(h: &Headless) -> f64 {
super::frames(&h.view.ctx).iter().map(|f| f.dt * 1e3).fold(0.0, f64::max)
}
/// Turned off and on again later, the display starts a fresh count: the time it was off is not
/// one long frame (a red spike and a "worst" of the whole time off).
#[test]
fn turning_it_back_on_shows_no_spike_for_the_time_it_was_off() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
set(&mut h, json!({"show": true}));
for _ in 0..3 {
h.step();
}
assert!(!super::frames(&h.view.ctx).is_empty(), "frames are counted while it shows");
set(&mut h, json!({"show": false}));
h.step();
h.time += 1.5;
set(&mut h, json!({"show": true}));
for _ in 0..3 {
h.step();
}
assert!(worst_ms(&h) < 1000.0, "worst frame {} ms after turning it back on", worst_ms(&h));
}
/// A pause while it shows (window hidden, machine asleep) starts a fresh count too.
#[test]
fn a_long_pause_while_it_shows_is_not_a_frame() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
set(&mut h, json!({"show": true}));
for _ in 0..3 {
h.step();
}
h.time += 30.0;
for _ in 0..3 {
h.step();
}
assert!(worst_ms(&h) < 1000.0, "worst frame {} ms after a pause", worst_ms(&h));
}
/// Each window (egui context) keeps its own count.
#[test]
fn each_context_keeps_its_own_count() {
let (a, b) = (egui::Context::default(), egui::Context::default());
for t in [0.0, 0.016, 0.033] {
super::record(&a, t, 0, 0, 0.0);
}
assert_eq!(super::frames(&a).len(), 2);
assert!(super::frames(&b).is_empty());
let (frames, _) = super::record(&b, 100.0, 0, 0, 0.0);
assert!(frames.is_empty(), "a new context's first frame has no interval: {}", frames.len());
}
}
+7 -5
View File
@@ -234,14 +234,15 @@ pub struct Headless {
/// Logical size (points) and scale.
pub size: egui::Vec2,
pub pixels_per_point: f32,
time: f64,
pub(crate) time: f64,
frames: u64,
pub(crate) events: Vec<egui::Event>,
control: Sender<ControlRequest>,
quit: bool,
/// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host).
pub window_maximized: bool,
/// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first.
/// Window-management and pointer commands the app sent (`StartDrag`, `Maximized`,
/// `CursorPosition`, …), oldest first.
pub window_commands: Vec<ViewportCommand>,
/// The pointer cursor the last frame asked for (what a real host would show).
pub last_cursor: egui::CursorIcon,
@@ -342,7 +343,8 @@ impl Headless {
self.window_maximized = on;
self.window_commands.push(c);
}
ViewportCommand::StartDrag => self.window_commands.push(c),
// recorded, not carried out: the simulated pointer moves only when a test moves it
ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => self.window_commands.push(c),
_ => {}
}
}
@@ -3362,7 +3364,7 @@ mod tests {
}
/// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it
/// narrower, though the tabs are of different heights and widths.
/// narrower or taller, though the tabs hold different amounts: the window is one size on every tab.
#[test]
fn settings_tabs_keep_the_window_in_place() {
let mut h = demo([1300.0, 820.0]);
@@ -3381,7 +3383,7 @@ mod tests {
assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width());
heights.push(now.height());
}
assert!(heights.iter().any(|h| (h - first.height()).abs() > 20.0), "the tabs differ in height: {heights:?}");
assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height());
}
/// Settings → Import → "Show the import review when a camera or card is connected": a card that
+133 -2
View File
@@ -266,8 +266,48 @@ fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
// `locales/*-formats.json`.
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
/// exactly as its owner wrote it.
/// The web build's notice for a dropped file the browser could not read.
pub fn dropped_file_unreadable(name: &str, error: &str) -> String {
tr_format!("Could not read {name}: {e}", name = name, e = error)
}
/// The web build's notice for dropped files the library refused.
pub fn dropped_import_failed(error: &str) -> String {
tr_format!("Import failed: {e}", e = error)
}
/// The web build's notice for `n` dropped photos that joined the library.
pub fn dropped_photos_added(n: usize) -> String {
tr_format!("Added {n} photo{}", if n == 1 { "" } else { "s" }, n = n)
}
/// The web build's notice for `n` dropped photos that came back from Recently Deleted.
pub fn dropped_photos_restored(n: usize) -> String {
tr_format!("Restored {} photo{} from Recently Deleted", n, if n == 1 { "" } else { "s" })
}
/// The web build's notice for what a drop of photos came to, from the `library.import` report: every outcome that
/// happened (added, restored from Recently Deleted, not readable), joined with " · " as the desktop import's notice
/// joins its parts, or `None` when none did (duplicates only).
pub fn dropped_photos_outcome(report: &serde_json::Value) -> Option<String> {
let count = |k: &str| report[k].as_array().map_or(0, Vec::len);
let parts: Vec<String> = [
(count("imported"), dropped_photos_added as fn(usize) -> String),
(count("restored"), dropped_photos_restored),
(count("failed"), dropped_photos_unreadable),
]
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, say)| say(n))
.collect();
(!parts.is_empty()).then(|| parts.join(" · "))
}
/// The web build's notice for `n` dropped photos that could not be read.
pub fn dropped_photos_unreadable(n: usize) -> String {
tr_format!("{} photo{} not readable", n, if n == 1 { "" } else { "s" })
}
/// The title and text of the message box shown when the desktop app's window can't start
/// (issue #260), in the UI language: the error and, when there is one, the log file to look in.
pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) {
@@ -305,6 +345,8 @@ pub fn upper_first(text: &str) -> String {
}
}
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
/// exactly as its owner wrote it.
pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
@@ -1624,6 +1666,95 @@ mod ",
let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)");
assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]);
}
/// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently
/// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing.
#[test]
fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() {
set_language(Locale::En);
let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let png = |name: &str, seed: u8| {
let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data };
let bytes =
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(dir.join(name), bytes).unwrap();
dir.join(name).to_string_lossy().into_owned()
};
let (trashed, added) = (png("trashed.png", 1), png("new.png", 2));
let broken = dir.join("broken.jpg");
std::fs::write(&broken, b"not a jpeg at all").unwrap();
let mut s = lightcraft_engine::Session::new().with_fs();
let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap();
s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap();
let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap();
assert_eq!(
dropped_photos_outcome(&report).as_deref(),
Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"),
"{report}"
);
let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap();
assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}");
let _ = std::fs::remove_dir_all(dir);
}
/// Polish: the menu and control channel select it, dates read day.month.year, and every count
/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so
/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some.
#[test]
fn polish_switches_formats_dates_and_reads_every_count() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.polish", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Pl);
assert_eq!(Locale::Pl.name(), "Polski");
assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true));
assert_eq!(tr("File"), "Plik");
assert_eq!(tr("Exposure"), "Ekspozycja");
assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026");
assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20");
assert_eq!(date_group_label("2026-09", false), "09.2026");
assert_eq!(date_group_label("2026", false), "2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05");
for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] {
let s = if n == 1 { "" } else { "s" };
assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}"));
for (one, many, noun) in [
("photo waiting", "photos waiting", "Oczekujące zdjęcia"),
("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"),
("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"),
] {
assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}"));
}
assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})"));
assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}"));
}
let name = String::from("Color 1234.56 {title} 日本語");
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22"));
let saved = serde_json::to_string(&app.ui).unwrap();
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Pl);
let ctx = egui::Context::default();
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}");
let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::Pl);
}
let text = painted_text(&ctx, &mut app, Locale::Pl);
assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}");
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() {
assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}");
}
}
});
set_language(Locale::En);
}
}
#[cfg(test)]
+71 -5
View File
@@ -91,6 +91,10 @@ pub struct ImportDialog {
/// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]).
#[serde(skip)]
pub last_clicked: Option<usize>,
/// The library the scan was made in ([`lightcraft_engine::Session::library_generation`]): its duplicate
/// decisions and album ids belong to that catalog, so the review is not shown or confirmed in another.
#[serde(skip)]
pub library: Option<u64>,
}
/// A candidate's file type, as the review groups them: its format, or its extension when the
@@ -114,6 +118,11 @@ impl ImportDialog {
let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect();
ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() }
}
/// Whether the library the scan was made in is still the open one (a review that names none counts).
fn in_library(&self, app: &LightcraftApp) -> bool {
self.library.is_none_or(|g| g == app.session.library_generation())
}
/// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice
/// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by
/// mistake comes back by importing its file again (issue #706).
@@ -471,6 +480,9 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String
if app.scan.is_some() {
return Err("a scan is already running".into());
}
if app.review_pending.is_some() {
return Err("an import review is waiting for the open dialog to close".into());
}
let sources = paths.clone();
let (input, paths) = ScanInput::new(&mut app.session, &paths);
let progress = std::sync::Arc::new(ScanProgress::default());
@@ -556,6 +568,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
/// Collect a finished scan and open the review (called every frame).
pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
show_pending_review(app, ctx);
let Some(task) = app.scan.as_ref() else { return };
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let out = match task.rx.try_recv() {
@@ -626,7 +639,52 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
// only an album the review offers (it may have been deleted while the scan ran)
d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart()));
d.sources = task.sources;
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
d.library = Some(app.session.library_generation());
app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) });
show_pending_review(app, ctx);
}
/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings…
/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was
/// closed meanwhile (Settings can open another) is dropped.
fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) {
if app.review_pending.is_none() {
return;
}
if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending
&& !opts.in_library(app)
{
app.review_pending = None;
return;
}
if app.ui.dialog.is_none() {
app.ui.dialog = app.review_pending.take();
} else {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running.
#[cfg(test)]
pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box<dyn std::any::Any> {
stall_scan_of(app, false)
}
/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan.
#[cfg(test)]
pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box<dyn std::any::Any> {
let progress = std::sync::Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel::<ScanOutput>();
let guard = scan_guard(app, &progress, false);
app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard });
Box::new(tx)
}
/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's
/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan
/// runs, and a browse that finishes replaces `app.import`.
pub fn running(app: &LightcraftApp) -> bool {
app.import.is_some() || app.scan.is_some() || app.review_pending.is_some()
}
impl ScanTask {
@@ -636,9 +694,17 @@ impl ScanTask {
}
}
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`).
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another
/// import runs: replacing `app.import` would stop its worker.
pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> {
busy_synchronizing(app)?;
if !d.in_library(app) {
return Err("another library was opened since the photos were scanned: nothing was imported".into());
}
// a browse scan that finishes starts its own import, replacing this one
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) {
return Err(crate::i18n::tr("An import is running").to_string());
}
let queue = d.selected_paths();
if queue.is_empty() {
return Err("no photos selected".into());
@@ -691,8 +757,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window.
pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
busy_synchronizing(app)?;
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) {
return Err("an import is running".into());
if running(app) {
return Err(crate::i18n::tr("An import is running").to_string());
}
let undo0 = app.session.undo.len();
let total = paths.len();
@@ -1336,7 +1402,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
crate::widgets::announce(&resp, c.name.clone());
register(ui.ctx(), format!("import:{i}"), img);
let p = ui.painter();
p.rect_filled(img, 3.0, Color32::from_gray(30));
p.rect_filled(img, 3.0, t.skeleton);
// thumbnail (background job; the grid only asks for the cells in view)
let slot = Slot::Import(i as u32);
if !app.renderer.textures.contains_key(&slot)

Some files were not shown because too many files have changed in this diff Show More