Merge remote-tracking branch 'upstream/main' into fix/local-browse-stamps

# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Michael Anderson
2026-10-15 09:08:52 -05:00
75 changed files with 7159 additions and 196 deletions
Generated
+1
View File
@@ -3304,6 +3304,7 @@ dependencies = [
"lightcraft-color",
"lightcraft-denoise",
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
+1 -1
View File
@@ -426,7 +426,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
+5 -3
View File
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
@@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%).
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
## Upcoming
@@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
2. CR3 for every Canon body (25–45 h)
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
4. Full-resolution zoom (8–15 h)
4. ~~Full-resolution zoom~~ (done 2026-10-12)
5. AI masks: Subject / Sky / People (40–70 h + model decision)
6. Compressed ORF and HE NEF (28–55 h)
7. Lens profile database of our own (40–80 h)
@@ -191,6 +191,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
+1
View File
@@ -33,6 +33,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
+47
View File
@@ -0,0 +1,47 @@
//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and
//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes
//! nothing there, and probes no file (the library is made from the folder's own listing).
//!
//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>`
use std::time::Instant;
use lightcraft_catalog::{Op, Photo, Source};
use lightcraft_engine::Session;
use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with};
fn main() {
let Some(folder) = std::env::args().nth(1) else {
eprintln!("usage: sync_scan_bench <folder>");
return;
};
let mut s = Session::new();
let t = Instant::now();
let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None);
println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64());
for f in &files {
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00");
if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() {
eprintln!("could not add {name}");
}
}
for run in 0..2 {
let input = match SyncInput::new(&mut s, &folder, true) {
Ok(input) => input,
Err(e) => {
eprintln!("{e}");
return;
}
};
let t = Instant::now();
let changes = scan_with(input, &SyncProgress::default());
println!(
"run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)",
t.elapsed().as_secs_f64(),
changes.new.len(),
changes.missing.len(),
changes.metadata.len()
);
}
}
+147 -11
View File
@@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use super::{CommandSpec, always, bad, cmd, has_selection};
use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
@@ -54,10 +54,12 @@ impl NoDetector {
}
}
/// The YuNet loaded last, and from where.
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
let home = s
.face_models_dir
.as_deref()
@@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete
let d = Arc::new(
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
);
#[cfg(not(target_arch = "wasm32"))]
{
let net = d.clone();
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
}
*loaded = Some((path, d.clone()));
Ok(d)
}
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
#[derive(Clone, Debug, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GpuState {
/// The background check is still running.
pub checking: bool,
/// The GPU it runs on; `None` when it runs on the CPU.
pub device: Option<String>,
/// Why it runs on the CPU.
pub why: Option<String>,
}
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
}
fn set_gpu_state(what: &'static str, st: GpuState) {
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
match all.iter_mut().find(|(w, _)| *w == what) {
Some((_, old)) => *old = st,
None => all.push((what, st)),
}
}
/// The user chose the CPU for face models (Settings ▸ Faces).
pub(crate) fn cpu_only(s: &Session) -> bool {
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
}
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
/// The check shows in the activity stack while it runs.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn offer_gpu(
s: &Session,
what: &'static str,
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
) {
if cpu_only(s) {
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
return;
}
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
task.detail(what);
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
let _task = task;
let gpu = match lightcraft_gpu::faces::accelerator() {
Ok(gpu) => gpu,
Err(why) => {
log::info!("faces: {what} runs on the CPU ({why})");
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
};
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
match attach(gpu) {
Ok(()) => {
log::info!("faces: {what} runs on {device}");
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
}
Err(why) => {
log::info!("faces: {what} stays on the CPU: {why}");
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
}
});
if let Err(e) = spawned {
log::warn!("faces: could not check the GPU for {what}: {e}");
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
}
}
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.gpu";
if let Some(cpu) = p.get("cpuOnly") {
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
super::face_models::set_cpu_only(&dir, cpu)?;
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
#[cfg(not(target_arch = "wasm32"))]
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
#[cfg(target_arch = "wasm32")]
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
if cpu {
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
if let Some(net) = net {
net.detach();
}
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
}
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(d) = detector {
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
}
if let Some(e) = embedder {
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
}
}
}
}
Ok(gpu_json(s))
}
/// What `faces.gpu` and `faces.models.list` say about the GPU.
pub(crate) fn gpu_json(s: &Session) -> Value {
json!({
"cpuOnly": cpu_only(s),
"detection": gpu_state("detection"),
"recognition": gpu_state("recognition"),
})
}
fn face_json(f: &Face) -> Value {
json!({
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
@@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
)]
vec![
cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
),
cmd!(
query "faces.gpu",
"Face Models on the GPU",
[],
None,
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
always,
gpu
),
]
}
#[cfg(test)]
+12
View File
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
pub(super) enabled: bool,
/// The installed recogniser in use.
pub(super) embedder: Option<String>,
/// Keep every face model on the CPU, even when the GPU would be faster.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub(super) cpu_only: bool,
}
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
})
}
/// Keep the face models on the CPU (or let them use the GPU again).
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
let mut st = read_settings(dir);
st.cpu_only = on;
write_settings(dir, &st)
}
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
"dir": dir.as_ref().map(|d| d.display().to_string()),
"enabled": settings.enabled,
"embedder": settings.embedder,
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
"gpu": super::face_detect::gpu_json(s),
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
"runtime": cfg!(not(target_arch = "wasm32")),
"models": models,
+5
View File
@@ -321,6 +321,8 @@ mod imp {
return Ok(e.clone());
}
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
let net = embedder.clone();
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
let dim = match installed.manifest.output {
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
@@ -672,6 +674,9 @@ mod imp {
if let Some(w) = &s.faces.worker {
w.allow(target, pace);
}
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
let (runs, share) = crate::faces_worker::gpu_pace(pace);
lightcraft_gpu::faces::set_pace(runs, share);
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
let Some(id) = s.faces.queue.pop() else { break };
let Some(job) = prepare(s, id, true) else { continue };
+80 -4
View File
@@ -150,6 +150,55 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas
Ok(json!({"activeMask": active}))
}
/// Most pieces a tapered ⇧-click segment is cut into.
const MAX_TAPER_STEPS: usize = 64;
/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment
/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the
/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their
/// maximum, so the pieces join without building up.
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
let Some(&first) = to.points.first() else { return vec![to] };
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
// pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any)
let pressured = !last.pressure.is_empty() || !to.pressure.is_empty();
let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0));
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
let mut points = Vec::with_capacity(to.points.len() + 1);
points.push(from);
points.extend(to.points.iter().copied());
let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() };
return vec![BrushStroke { points, pressure, ..to }];
}
// about 5% of size, or 2 points of feather or flow, per piece
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0]
.into_iter()
.filter(|v| v.is_finite())
.fold(1.0f64, f64::max);
let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS);
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t));
let mut out: Vec<BrushStroke> = (0..steps)
.map(|i| {
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32;
BrushStroke {
points: vec![at(t0), at(t1)],
size: lerp(last.size, to.size, t1),
feather: lerp(last.feather, to.feather, t1),
flow: lerp(last.flow, to.flow, t1),
pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() },
..to.clone()
}
})
.collect();
if to.points.len() > 1 {
out.push(to);
}
out
}
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
}
@@ -233,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Stroke",
[],
None,
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full)}",
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
has_active,
|s, p| {
let pts: Vec<Point> = p
@@ -255,10 +304,12 @@ pub fn specs() -> Vec<CommandSpec> {
auto_mask: bool_or(p, "autoMask", false),
pressure: pressure_list(p.get("pressure"), pts_len)?,
};
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
Some(i) => i,
None => {
@@ -271,7 +322,28 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
// the previous stroke's end and its brush values (not its points)
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
Some((
*l.points.last()?,
BrushStroke {
points: Vec::new(),
size: l.size,
feather: l.feather,
flow: l.flow,
// the pressure the last dab was painted with, when the stroke recorded any
pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] },
..BrushStroke::default()
},
))
});
match anchor {
Some((from, last)) => {
strokes.extend(connected_strokes(from, &last, stroke));
connected = true;
}
None => strokes.push(stroke),
}
} else {
m.components.push(MaskComponent {
name: None,
@@ -281,7 +353,11 @@ pub fn specs() -> Vec<CommandSpec> {
});
}
Ok(())
})
})?;
if let Some(o) = out.as_object_mut() {
o.insert("connected".into(), json!(connected));
}
Ok(out)
}
),
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
+21 -7
View File
@@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize {
cap.map_or(n, |c| n.min(c.max(1)))
}
/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be
/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders
/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes.
pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) {
match pace {
Pace::Pause | Pace::Light => (1, 0.25),
Pace::Normal => (2, 0.75),
Pace::Full => (4, 1.0),
}
}
/// One photo's faces to embed (and, if `detect`, to find first).
pub(crate) struct Prepared {
/// `<model id>@<sha256>` the embeddings are for.
@@ -201,7 +212,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
let img = Rgb { data: &rgb, width: w, height: h };
let (iw, ih) = embedder.input_size();
let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32));
let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo
// every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo)
let mut faces: Vec<(Rect, Option<Vec<u8>>, Rect)> = todo
.into_iter()
.map(|rect| {
let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0));
@@ -218,20 +230,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
ih,
),
};
let v = crop.and_then(|c| embedder.embed(&c).ok());
// shown by the detector's box where it found this face: every face then has the same tightness
let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect);
(rect, v, view)
(rect, crop, view)
})
.collect();
// a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass
let new_faces = if detect {
detected.map(|faces| {
detected.map(|found| {
let mut rects = Vec::new();
for f in faces.iter().filter(|f| f.score >= REGION_SCORE) {
for f in found.iter().filter(|f| f.score >= REGION_SCORE) {
let Some(rect) = face_rect(f) else { continue };
let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok());
results.push((rect, v, rect));
faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect));
rects.push(rect);
}
rects
@@ -239,6 +249,10 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
} else {
None
};
let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect();
let mut embedded = embedder.embed_batch(&crops).into_iter();
let results: Vec<(Rect, Option<Vec<f32>>, Rect)> =
faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect();
(results, new_faces)
}));
let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None));
+27 -3
View File
@@ -382,6 +382,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
expand_noting(paths, skip, limits, None)
}
/// [`expand_within`], handing the listing of every folder that is walked to `note`
/// ([`crate::walk::files_in_noting`]).
pub(crate) fn expand_noting(
paths: &[String],
skip: Option<&Path>,
limits: crate::walk::Limits,
mut note: Option<crate::walk::NoteFolder>,
) -> (Vec<String>, bool) {
let mut out = Vec::new();
let mut truncated = false;
for p in paths {
@@ -390,7 +401,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk:
continue;
}
if p.is_dir() {
let w = crate::walk::files_in(p, skip, limits, is_supported);
let w = match note.as_mut() {
Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note),
None => crate::walk::files_in(p, skip, limits, is_supported),
};
if w.truncated {
log::warn!(
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
@@ -638,6 +652,11 @@ impl ScanInput {
(input, paths.to_vec())
}
/// The folder a scan never enters (the library's own).
pub(crate) fn skip(&self) -> Option<&Path> {
self.skip.as_deref()
}
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
std::sync::Arc::clone(&self.library)
@@ -656,9 +675,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
/// has) when `progress.cancel` is set.
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
let files = expand(paths, input.skip.as_deref());
scan_files(input, files, progress)
}
/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]).
pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
let by_path = library_files(&input.library, &files);
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
progress.total.store(todo.len(), Relaxed);
+34
View File
@@ -2189,6 +2189,40 @@ mod tests {
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of
// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and
// at a corner, with tone, colour and detail edits on a camera-sized photo
#[test]
fn one_to_one_window_pixels_are_export_pixels() {
use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow};
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}");
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 0.7;
d.light.contrast = 25.0;
d.color.saturation = 20.0;
s.set_develop(id, d, "Edits").unwrap();
let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image;
assert_eq!((export.width, export.height), (w, h), "a full-size export");
for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] {
let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image;
assert_eq!((tile.width, tile.height), (win.w, win.h));
let mut worst = 0u8;
for y in 0..win.h {
for x in 0..win.w {
let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]);
for k in 0..3 {
worst = worst.max(a[k].abs_diff(b[k]));
}
}
}
assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels");
}
}
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
/// one nothing can decode) is not read again.
#[test]
+8 -2
View File
@@ -66,6 +66,13 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as
/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss).
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
sidecar_candidates(original, naming).find(|p| p.is_file())
}
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
/// then under the other naming (the full name also with the photo extension in the other case),
/// each as `.xmp`, `.XMP` and `.Xmp`.
pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> {
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let base = Path::new(original).with_extension("");
@@ -73,7 +80,7 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect();
[naming, other]
.into_iter()
.flat_map(|n| {
.flat_map(move |n| {
let mut v = vec![sidecar_path(original, n)];
if n == SidecarNaming::Full {
v.extend(full_variants.iter().map(|o| sidecar_path(o, n)));
@@ -81,7 +88,6 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
v
})
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
.find(|p| p.is_file())
}
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
+167 -12
View File
@@ -38,7 +38,7 @@
//! not cancelled midway.
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
@@ -203,18 +203,37 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
// The folder is walked once, and its listing answers for the library's photos in it: on a
// network share that is one request for a folder's names where it was several for each
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
// (how many photos there are to check is known before the walk: the count shows while it runs)
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
let mut listing = Listing::default();
let (files, _) = crate::import::expand_noting(
std::slice::from_ref(&folder),
scan.skip(),
crate::walk::Limits::default(),
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
);
let listing = &listing;
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
let (out, checks) = (crate::import::scan_files(scan, files, &progress.files), check_photos(&photos, &labels, listing, progress));
#[cfg(not(target_arch = "wasm32"))]
let (out, checks) = std::thread::scope(|sc| {
let photos = &photos;
let labels = &labels;
let checks = sc.spawn(move || check_photos(photos, labels, progress));
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
let checks = sc.spawn(move || check_photos(photos, labels, listing, progress));
let out = crate::import::scan_files(scan, files, &progress.files);
// (a check that panicked counts as nothing found; the scan still reports the rest)
(out, checks.join().unwrap_or_default())
});
log::debug!(
"synchronize: {} folders listed; the disk was asked {} times about the library's photos",
listing.folders.len(),
listing.asked.load(std::sync::atomic::Ordering::Relaxed)
);
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
for ((p, _), check) in photos.iter().zip(checks) {
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
@@ -335,11 +354,136 @@ enum Check {
News,
}
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
/// The listings of the folders a scan walked: what is in each, by name. It answers "is this file
/// there" for the library's photos and their sidecars without asking the disk, which on a
/// network share is a round trip for every name (and a photo without a sidecar took five).
///
/// It answers only what holds on any file system. A name is there when the folder lists exactly
/// that name ([`asks_the_disk`] aside). It is not there when the folder was listed to its end,
/// every name in the folder is ASCII, the name asked about is plain ASCII ([`plain_name`]), and
/// no name in the folder is that name in another case. Anything else is not answered, and the
/// disk is asked as before: a name that differs in case only, a link, a folder that was not
/// walked or not to its end, and every name that is not listed in a folder that holds a name
/// outside ASCII. (File systems hold names outside ASCII equal in more ways than can be listed
/// here: by case, where the capital sharp s is `SS`; by how accents are written; and some pass
/// over marks altogether.)
///
/// Where the listing and a look-up would differ, the listing is the folder as the scan saw it:
/// a file it lists is there though it could not be opened by its path (a folder that may be
/// listed but not searched, a path longer than the system takes), and a share that drops after
/// the walk does not turn every photo the listing answers for into a missing one.
#[derive(Default)]
pub(crate) struct Listing {
folders: HashMap<PathBuf, Listed>,
/// How often the disk was asked after all (for the tests, and the log).
pub(crate) asked: std::sync::atomic::AtomicUsize,
}
/// One folder's listing.
struct Listed {
/// name → what it is.
names: HashMap<String, crate::walk::EntryKind>,
/// The names, lower-cased (only kept for a folder whose names are all ASCII): what a plain
/// name asked about could be in another case.
lower: std::collections::HashSet<String>,
/// Every entry was read, and every name is ASCII: a plain name that is not listed, in any
/// case, is not there.
sure: bool,
}
/// Whether the disk is asked about `name` whatever the listing holds: a name Windows takes for
/// a device (`NUL`, `aux.jpg`: there in every folder, or not openable where it is listed).
fn asks_the_disk(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase();
// (`COM` and `LPT` with any one digit, the superscript ones Windows also takes among them)
let numbered = ["com", "lpt"].iter().any(|port| {
let mut rest = stem.strip_prefix(port).unwrap_or_default().chars();
matches!((rest.next(), rest.next()), (Some(d), None) if d.is_ascii_digit() || ['\u{b9}', '\u{b2}', '\u{b3}'].contains(&d))
});
numbered || ["con", "prn", "aux", "nul", "conin$", "conout$", "clock$"].contains(&stem.as_str())
}
/// Whether `name` can only be found under itself in some case: ASCII, and nothing in it that a
/// file system resolves another way (a short name with `~`, a stream after `:`, dots or spaces
/// at its end, which Windows drops).
fn plain_name(name: &str) -> bool {
name.is_ascii() && !name.contains(['~', ':']) && !name.ends_with(['.', ' ']) && !name.is_empty()
}
impl Listing {
/// Take the listing of `folder` from the walk ([`crate::walk::files_in_noting`]).
pub(crate) fn note(&mut self, folder: &Path, entries: Vec<(std::ffi::OsString, crate::walk::EntryKind)>, complete: bool) {
let mut sure = complete;
let mut names = HashMap::with_capacity(entries.len());
let mut lower = std::collections::HashSet::new();
for (name, kind) in entries {
match name.into_string() {
Ok(name) => {
sure &= name.is_ascii();
lower.insert(name.to_ascii_lowercase());
names.insert(name, kind);
}
// (a name that is no text is not one a photo's path can be compared with)
Err(_) => sure = false,
}
}
if !sure {
lower.clear();
}
self.folders.insert(folder.to_path_buf(), Listed { names, lower, sure });
}
/// What the listing of `path`'s folder says of it: its kind when it is listed under exactly
/// this name, `Some(None)` when it is surely not there, `None` when the listing can't say.
fn kind(&self, path: &Path) -> Option<Option<crate::walk::EntryKind>> {
let name = path.file_name()?.to_str()?;
// (the path ends in that name: no `/.` or separator after it, which the disk would resolve)
if !path.to_str()?.ends_with(name) {
return None;
}
if asks_the_disk(name) {
return None;
}
let folder = self.folders.get(path.parent()?)?;
if let Some(kind) = folder.names.get(name) {
return Some(Some(*kind));
}
(folder.sure && plain_name(name) && !folder.lower.contains(&name.to_ascii_lowercase())).then_some(None)
}
/// `Path::exists`, where the listing can say.
pub(crate) fn exists(&self, path: &Path) -> Option<bool> {
match self.kind(path)? {
Some(crate::walk::EntryKind::Other) => None,
Some(_) => Some(true),
None => Some(false),
}
}
/// `Path::is_file`, where the listing can say.
pub(crate) fn is_file(&self, path: &Path) -> Option<bool> {
match self.kind(path)? {
Some(crate::walk::EntryKind::Other) => None,
Some(kind) => Some(kind == crate::walk::EntryKind::File),
None => Some(false),
}
}
/// The listing's answer, or the disk's where it has none.
fn or_ask(&self, answer: Option<bool>, ask: impl FnOnce() -> bool) -> bool {
answer.unwrap_or_else(|| {
self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
ask()
})
}
}
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, listing: &Listing) -> Check {
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
if !Path::new(file).exists() {
let file = Path::new(file);
if !listing.or_ask(listing.exists(file), || file.exists()) {
Check::Missing
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, listing) {
Check::News
} else {
Check::Nothing
@@ -348,12 +492,12 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec<Check> {
use std::sync::atomic::Ordering::Relaxed;
progress.to_check.store(photos.len(), Relaxed);
let cancel = &progress.files.cancel;
let check = |p: &Photo, naming: SidecarNaming| {
let c = check_photo(p, naming, labels);
let c = check_photo(p, naming, labels, listing);
progress.checked.fetch_add(1, Relaxed);
c
};
@@ -421,10 +565,17 @@ impl Session {
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
/// (imported, or edited here) and says something the library doesn't.
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, listing: &Listing) -> bool {
let Source::File { path } = &p.source else { return false };
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
let Ok(meta) = std::fs::metadata(&file) else { return false };
// ([`crate::sidecar::find_sidecar`], each name looked up in the listing before the disk)
// (a name the listing holds is read for its date next, and one that can't be read is passed
// over for the next name, as a name that is no file was)
let found = crate::sidecar::sidecar_candidates(path, naming).find_map(|c| {
let there = listing.or_ask(listing.is_file(&c), || c.is_file());
let meta = there.then(|| std::fs::metadata(&c).ok()).flatten().filter(std::fs::Metadata::is_file)?;
Some((c, meta))
});
let Some((file, meta)) = found else { return false };
if meta.len() > MAX_SIDECAR_BYTES {
return false;
}
@@ -708,3 +859,7 @@ impl SyncCommit {
self.report
}
}
#[cfg(test)]
#[path = "sync/tests_listing.rs"]
mod tests_listing;
+192
View File
@@ -0,0 +1,192 @@
//! Synchronize Folder answers "is the photo's file there" and "has it a sidecar" from the
//! listing of the folder its scan has just made, instead of asking the disk about every photo.
//!
//! On a network share each question is a round trip (about 5 ms a file on a hard disk over SMB,
//! measured), and a photo without a sidecar took five of them; the listing has every name.
//! The listing answers only what it can be sure of on any file system: where a name could match
//! another way (case, letters outside ASCII, a link), the disk is asked as before.
use super::*;
use crate::walk::EntryKind::{File, Folder, Other};
fn listing(folders: &[(&str, &[(&str, crate::walk::EntryKind)], bool)]) -> Listing {
let mut l = Listing::default();
for (dir, names, complete) in folders {
l.note(Path::new(dir), names.iter().map(|(n, k)| (std::ffi::OsString::from(n), *k)).collect(), *complete);
}
l
}
/// Given the listing of a folder, when it is asked about a name, then it says "there" for a
/// name it lists, "not there" for one nothing in the folder could be taken for, and nothing
/// (ask the disk) wherever a file system could see it otherwise.
#[test]
fn the_listing_answers_only_what_it_is_sure_of() {
let l = listing(&[
(
"/pics/trip",
&[("IMG_1.CR3", File), ("IMG_1.xmp", File), ("img_2.jpg", File), ("sub", Folder), ("link.jpg", Other), (".hidden.xmp", File)],
true,
),
("/pics/cut", &[("IMG_1.CR3", File)], false),
("/pics/names", &[("IMG_1.CR3", File), ("Größe.JPG", File)], true),
// (the capital sharp s, which a file system that folds case takes for `SS`)
("/pics/sharp", &[("IMG_1.CR3", File), ("STRA\u{1e9e}E.jpg", File)], true),
("/pics/devices", &[("aux.jpg", File), ("COM\u{b9}.jpg", File), ("IMG_1.CR3", File)], true),
]);
let (there, gone, ask) = (Some(true), Some(false), None);
for (path, exists, is_file) in [
("/pics/trip/IMG_1.CR3", there, there),
("/pics/trip/IMG_1.xmp", there, there),
("/pics/trip/.hidden.xmp", there, there),
("/pics/trip/sub", there, gone),
("/pics/trip/IMG_9.CR3", gone, gone),
("/pics/trip/IMG_1.XMP", ask, ask),
("/pics/trip/IMG_2.JPG", ask, ask),
("/pics/trip/link.jpg", ask, ask),
("/pics/trip/SUB", ask, ask),
// a folder the walk did not list to its end, or not at all
("/pics/cut/IMG_1.CR3", there, there),
("/pics/cut/IMG_2.CR3", ask, ask),
("/pics/other/IMG_1.CR3", ask, ask),
// a folder that holds a name outside ASCII: what it lists is there, and nothing is surely not there (file
// systems hold such names equal in more ways than one)
("/pics/names/IMG_1.CR3", there, there),
("/pics/names/Größe.JPG", there, there),
("/pics/names/IMG_2.CR3", ask, ask),
("/pics/names/größe.JPG", ask, ask),
("/pics/trip/Größe.JPG", ask, ask),
("/pics/sharp/strasse.jpg", ask, ask),
("/pics/sharp/STRASSE.xmp", ask, ask),
("/pics/sharp/IMG_2.CR3", ask, ask),
// names Windows resolves another way: a short name, a stream, dots or spaces at the end, a device
("/pics/trip/IMG_10~1.CR3", ask, ask),
("/pics/trip/IMG_1.CR3::$DATA", ask, ask),
("/pics/trip/IMG_1.CR3.", ask, ask),
("/pics/trip/IMG_1.CR3 ", ask, ask),
("/pics/devices/aux.jpg", ask, ask),
("/pics/devices/NUL", ask, ask),
("/pics/devices/nul.xmp", ask, ask),
("/pics/devices/COM1 .jpg", ask, ask),
("/pics/devices/COM\u{b9}.jpg", ask, ask),
("/pics/trip/con", ask, ask),
("/pics/trip/PRN.txt", ask, ask),
("/pics/trip/com2.jpg", ask, ask),
("/pics/trip/LPT9.xmp", ask, ask),
("/pics/trip/lpt0", ask, ask),
("/pics/trip/nul.tar.gz", ask, ask),
("/pics/trip/CONIN$", ask, ask),
("/pics/trip/com10.jpg", gone, gone),
("/pics/trip/computer.jpg", gone, gone),
("/pics/devices/IMG_1.CR3", there, there),
("/pics/devices/auxiliary.jpg", ask, ask),
("/pics/trip/auxiliary.jpg", gone, gone),
// a path that does not end in its file's name is the disk's to resolve
("/pics/trip/IMG_1.CR3/", ask, ask),
("/pics/trip/IMG_1.CR3/.", ask, ask),
("/pics/trip/", ask, ask),
("", ask, ask),
] {
assert_eq!((l.exists(Path::new(path)), l.is_file(Path::new(path))), (exists, is_file), "{path:?}");
}
// a name that is no text: nothing in its folder is surely not there
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let mut l = Listing::default();
l.note(
Path::new("/pics/raw"),
vec![(std::ffi::OsString::from("IMG_1.CR3"), File), (std::ffi::OsString::from_vec(vec![0x66, 0xff, 0x6f]), File)],
true,
);
assert_eq!((l.exists(Path::new("/pics/raw/IMG_1.CR3")), l.exists(Path::new("/pics/raw/IMG_2.CR3"))), (there, ask));
}
}
fn photo(c: &mut Catalog, path: &Path) -> Arc<Photo> {
let id = c.alloc_photo_id();
let name = path.file_name().unwrap().to_string_lossy().to_string();
c.apply(lightcraft_catalog::Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: path.to_string_lossy().to_string() }, &name, "JPEG", 6, 4, "2020-01-01T00:00:00")),
})
.unwrap();
Arc::clone(c.photo(id).unwrap())
}
/// Given a folder on disk whose photos are there or gone, with sidecars under every name a
/// sidecar can have, when each photo is checked with the folder's listing, then the answer is
/// the one the disk gives; and for photos with plain names the disk was asked nothing but the
/// sidecars' dates.
#[test]
fn a_check_from_the_listing_is_the_check_from_the_disk() {
let dir = std::env::temp_dir().join(format!("lc-listing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
let news = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="5"/></rdf:RDF></x:xmpmeta>"#;
for name in [
"plain.jpg",
"stem.jpg",
"stem.xmp",
"full.jpg",
"full.jpg.xmp",
"upper.jpg",
"upper.XMP",
"sub/deep.jpg",
"sub/deep.xmp",
"Größe.jpg",
"Größe.xmp",
"dir.jpg.xmp",
] {
std::fs::write(dir.join(name), if name.ends_with("mp") || name.ends_with("MP") { news.as_bytes() } else { b"jpeg" }).unwrap();
}
let mut c = Catalog::new();
let photos: Vec<Arc<Photo>> =
["plain.jpg", "stem.jpg", "full.jpg", "upper.jpg", "sub/deep.jpg", "Größe.jpg", "gone.jpg", "sub/gone.jpg", "dir.jpg"]
.iter()
.map(|n| photo(&mut c, &dir.join(n)))
.collect();
// (`dir.jpg` is gone, though a sidecar of its name is there)
let mut l = Listing::default();
let walked =
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
assert_eq!(walked.files.len(), 12);
let nothing = Listing::default();
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for p in &photos {
let from_disk = check_photo(p, naming, &c, &nothing);
assert_eq!(check_photo(p, naming, &c, &l), from_disk, "{:?} {naming:?}", p.file_name);
}
}
let expected = [Check::Nothing, Check::News, Check::News, Check::News, Check::News, Check::News, Check::Missing, Check::Missing, Check::Missing];
assert_eq!(photos.iter().map(|p| check_photo(p, SidecarNaming::Stem, &c, &l)).collect::<Vec<_>>(), expected);
// what the disk was asked about the same photos, with the listing and without: fewer questions with it
let asked = |l: &Listing| {
for p in &photos {
check_photo(p, SidecarNaming::Stem, &c, l);
}
l.asked.load(std::sync::atomic::Ordering::Relaxed)
};
let mut with = Listing::default();
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
let (with, without) = (asked(&with), asked(&Listing::default()));
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
// names are found in any case, 21 where they are not)
assert!(without >= 20, "{without}");
// (with: this folder holds `Größe.*`, so what it does not list is still asked about: the nine names of the
// sidecar `plain.jpg` has not (`.xmp`, `.XMP` and `.Xmp` of `plain`, `plain.jpg` and `plain.JPG`), the three
// `full.jpg` has not, `upper.xmp`, and the two photos that are gone. What it lists is not asked about, nor
// anything in `sub`, whose names are all ASCII)
assert_eq!(with, 15, "asked {with} times with the listing, {without} without");
// a folder of plain names only: the disk is asked nothing at all
let sub = dir.join("sub");
let mut only_plain = Listing::default();
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
let mut c2 = Catalog::new();
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
let p = photo(&mut c2, &sub.join(name));
assert_eq!(check_photo(&p, SidecarNaming::Stem, &c2, &only_plain), expected, "{name}");
}
assert_eq!(only_plain.asked.load(std::sync::atomic::Ordering::Relaxed), 0, "every answer came from the listing");
let _ = std::fs::remove_dir_all(&dir);
}
+67
View File
@@ -313,6 +313,44 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
assert_eq!(s.catalog.to_snapshot(), snapshot);
}
/// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight
/// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new
/// values when it changed, and a plain dab when there is nothing to connect to.
#[test]
fn connected_brush_strokes_start_where_the_last_one_ended() {
use lightcraft_develop::MaskShape;
use lightcraft_geom::Point;
let mut s = demo();
let id = s.active().unwrap();
let r = s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "connect": true})).unwrap();
assert_eq!(r["connected"], false, "nothing to connect to: a dab");
let strokes = |s: &Session| {
let d = s.develop_of(id).unwrap();
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
strokes.clone()
};
assert_eq!(strokes(&s)[0].points.len(), 1);
let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap();
assert_eq!(r["connected"], true);
let line = strokes(&s);
assert_eq!(line.len(), 2);
assert_eq!(line[1].points, vec![Point::new(0.2, 0.5), Point::new(0.6, 0.5)]);
// a bigger, softer brush: the segment tapers from the last stroke's values to the new ones
let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "feather": 80, "connect": true})).unwrap();
assert_eq!(r["connected"], true);
let all = strokes(&s);
let taper = &all[2..];
assert!(taper.len() > 4, "{} pieces", taper.len());
assert_eq!(taper[0].points[0], Point::new(0.6, 0.5), "from the last stroke's end");
assert_eq!(*taper.last().unwrap().points.last().unwrap(), Point::new(0.6, 0.9), "to the click");
assert!(taper.windows(2).all(|w| w[1].size >= w[0].size && w[1].feather >= w[0].feather && w[0].points[1] == w[1].points[0]));
assert!((taper.last().unwrap().size - 0.04).abs() < 1e-12 && (taper.last().unwrap().feather - 80.0).abs() < 1e-9);
assert!(taper[0].size > 0.02 && taper[0].size < 0.04);
// all of it is one undo step
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(strokes(&s).len(), 2);
}
#[test]
fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
let mut s = demo();
@@ -330,6 +368,35 @@ fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
assert!(!json.contains("pressure"), "and stores none: {json}");
}
/// A ⇧-click line carries pen pressure: the last dab's at its start and the click's at its end, stepping along a
/// tapered line; with no pressure on either side there is none.
#[test]
fn connected_brush_strokes_carry_pen_pressure() {
use lightcraft_develop::MaskShape;
let mut s = demo();
let id = s.active().unwrap();
let strokes = |s: &Session| {
let d = s.develop_of(id).unwrap();
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
strokes.clone()
};
s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "pressure": [0.4]})).unwrap();
s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "pressure": [1.0], "connect": true})).unwrap();
assert_eq!(strokes(&s)[1].pressure, vec![0.4, 1.0]);
s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "pressure": [0.2], "connect": true})).unwrap();
let taper = &strokes(&s)[2..];
assert!(taper.iter().all(|t| t.pressure.len() == 2));
assert!((taper[0].pressure[0] - 1.0).abs() < 1e-6 && (taper.last().unwrap().pressure[1] - 0.2).abs() < 1e-6);
assert!(taper.windows(2).all(|w| w[1].pressure[0] <= w[0].pressure[0]));
// a mouse: no pressure anywhere
let mut m = demo();
m.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02})).unwrap();
m.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap();
let d = m.develop_of(m.active().unwrap()).unwrap();
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert!(strokes[1].pressure.is_empty());
}
#[test]
fn masks_crop_and_render() {
let mut s = demo();
+14
View File
@@ -627,3 +627,17 @@ fn recognition_cannot_be_turned_on_without_a_chosen_model() {
assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_cpu_only_switch_is_kept_and_bad_values_are_refused() {
let d = temp("cpu-only");
let mut s = session(&d);
assert_eq!(s.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], false);
assert_eq!(s.execute("faces.gpu", &json!({"cpuOnly": true})).unwrap()["cpuOnly"], true);
// kept with the models, so a new session starts on the CPU too
let mut again = session(&d);
assert_eq!(again.execute("faces.models.list", &json!({})).unwrap()["gpu"]["cpuOnly"], true);
assert!(again.execute("faces.gpu", &json!({"cpuOnly": "yes"})).is_err());
assert_eq!(again.execute("faces.gpu", &json!({"cpuOnly": false})).unwrap()["cpuOnly"], false);
let _ = std::fs::remove_dir_all(d);
}
+93 -4
View File
@@ -47,10 +47,35 @@ fn hidden(p: &Path) -> bool {
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
/// What a folder's listing says an entry is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntryKind {
File,
Folder,
/// A link, or an entry whose kind the listing did not say.
Other,
}
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
/// stopped inside the folder, or an entry could not be read).
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked {
walk(root, skip, limits, keep, None)
}
/// [`files_in`], handing the listing of every folder it reads to `note`: who walks a folder to
/// find its files has, with that, the answer to "is this name there" for everything in it, and
/// need not ask the disk again (a round trip for each name on a network share).
pub fn files_in_noting(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool, note: NoteFolder) -> Walked {
walk(root, skip, limits, keep, Some(note))
}
fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool, mut note: Option<NoteFolder>) -> Walked {
let mut out = Walked::default();
// canonical folders reached through a link, so a link loop is walked at most once
let mut linked: HashSet<PathBuf> = HashSet::new();
@@ -77,17 +102,41 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
}
let Ok(rd) = std::fs::read_dir(&path) else { continue };
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for e in rd.flatten() {
// (the folder's listing, for who asked for it; all of it unless something below says otherwise)
let mut listed = note.is_some().then(Vec::new);
let mut complete = true;
for e in rd {
let Ok(e) = e else {
complete = false;
continue;
};
if out.entries >= limits.max_entries {
out.truncated = true;
complete = false;
break;
}
out.entries = out.entries.saturating_add(1);
let p = e.path();
if hidden(&p) {
// (a hidden entry's kind is only asked for when the listing is wanted; every other
// entry's is asked once)
let is_hidden = hidden(&p);
if is_hidden && listed.is_none() {
continue;
}
let Ok(ft) = e.file_type() else { continue };
let ft = e.file_type();
if let Some(listed) = listed.as_mut() {
let kind = match &ft {
Ok(t) if t.is_symlink() => EntryKind::Other,
Ok(t) if t.is_dir() => EntryKind::Folder,
Ok(t) if t.is_file() => EntryKind::File,
_ => EntryKind::Other,
};
listed.push((e.file_name(), kind));
}
if is_hidden {
continue;
}
let Ok(ft) = ft else { continue };
let dir = if ft.is_symlink() {
// a link (or a Windows junction): a folder is entered once, a file is a file
match std::fs::metadata(&p) {
@@ -105,6 +154,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
};
children.push((p, dir));
}
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
note(&path, listed, complete);
}
children.sort();
let below = depth.saturating_add(1);
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
@@ -123,6 +175,43 @@ mod tests {
d
}
/// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked
/// with its listings noted, then the walk finds what it finds without them, and each folder's
/// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor
/// folder) and says it is all of them; a walk that stops inside a folder says it is not.
#[test]
fn a_walk_notes_each_folders_listing() {
let root = tmp("noting");
std::fs::create_dir_all(root.join("sub")).unwrap();
for name in ["a.jpg", ".hidden", "sub/b.jpg"] {
std::fs::write(root.join(name), b"x").unwrap();
}
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("a.jpg"), root.join("link.jpg")).unwrap();
let mut noted: Vec<(PathBuf, Vec<(String, EntryKind)>, bool)> = Vec::new();
let walked = files_in_noting(&root, None, Limits::default(), |_| true, &mut |dir, entries, complete| {
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
noted.push((dir.to_path_buf(), entries, complete));
});
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
let named = |n: &str, k: EntryKind| (n.to_string(), k);
let mut top = vec![named(".hidden", EntryKind::File), named("a.jpg", EntryKind::File), named("sub", EntryKind::Folder)];
if cfg!(unix) {
top.insert(2, named("link.jpg", EntryKind::Other));
}
assert_eq!(noted, [(root.clone(), top, true), (root.join("sub"), vec![named("b.jpg", EntryKind::File)], true)]);
// stopped inside the first folder: its listing is not all of it
let mut cut = Vec::new();
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
cut.push((dir.to_path_buf(), entries.len(), complete))
});
assert!(walked.truncated);
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
let d = tmp("order");
+2 -1
View File
@@ -5,7 +5,8 @@
//!
//! Everything here treats model files and manifests as hostile input: sizes are capped, numbers must be
//! finite and sane, and a malformed file is an error, never a panic. Detection and recognition run
//! on the same checked, single-thread pure-Rust CPU interpreter.
//! on the same checked, single-thread pure-Rust CPU interpreter; an [`net::Accelerator`] (the GPU, in `lightcraft-gpu`) can
//! take its convolutions once it agrees with it.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+451 -3
View File
@@ -9,8 +9,10 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, PoisonError, RwLock};
mod kernels;
pub mod program;
mod shape;
use crate::graph::{Data, Graph, Node};
@@ -64,9 +66,64 @@ impl Tensor {
}
}
/// One convolution, as handed to an [`Accelerator`]: batch 1, planar (NCHW) float32, already checked.
///
/// `out[co][oy][ox] = bias[co] + Σ weights[co][ci][ky][kx] · input[g·cg + ci][oy·sh + ky − pt][ox·sw + kx − pl]`
/// with `g = co / (cout / groups)` and zero outside the input.
#[derive(Clone, Copy, Debug)]
pub struct ConvJob<'a> {
/// The node's index in the graph: the same for every run of a net, so its weights can stay on the device.
pub layer: usize,
pub cin: usize,
pub h: usize,
pub w: usize,
pub cout: usize,
pub groups: usize,
pub kh: usize,
pub kw: usize,
pub sh: usize,
pub sw: usize,
pub pt: usize,
pub pl: usize,
pub oh: usize,
pub ow: usize,
/// `[cout][cin / groups][kh][kw]`.
pub weights: &'a [f32],
/// `[cout]`, if the node has one.
pub bias: Option<&'a [f32]>,
/// `[cin][h][w]`.
pub input: &'a [f32],
}
impl ConvJob<'_> {
/// Multiply-adds the convolution takes.
pub fn macs(&self) -> usize {
let per_group = (self.cin / self.groups.max(1)).saturating_mul(self.kh).saturating_mul(self.kw);
self.cout.saturating_mul(per_group).saturating_mul(self.oh).saturating_mul(self.ow)
}
}
/// Something that can run a network's convolutions faster than the CPU kernels (the GPU, in `lightcraft-gpu`).
///
/// The CPU interpreter stays the reference: a net only uses an accelerator that agreed with it on a test input
/// ([`Net::attach_checked`]), and the first error makes the net drop it and run on the CPU again.
pub trait Accelerator: Send + Sync {
/// What it runs on, for the user ("AMD Radeon RX 7900 (Vulkan)").
fn device(&self) -> String;
/// `[cout][oh][ow]`, or `Ok(None)` to leave this one to the CPU (too small to be worth a dispatch, say).
fn conv(&self, job: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String>;
/// The whole network on the device, keeping every value there between steps, for each of `inputs` (all of
/// `program.lens[INPUT]` values) in one go: per input, each of `program.outputs` in order. `Ok(None)` leaves the
/// run to the CPU path (which may still use [`Accelerator::conv`]).
fn run_program(&self, _program: &program::Program, _inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> {
Ok(None)
}
}
/// A loaded network, ready to run.
pub struct Net {
graph: Graph,
accelerator: RwLock<Option<Arc<dyn Accelerator>>>,
}
/// Validate semantics rather than silently ignoring unknown or mistyped attributes.
@@ -80,7 +137,75 @@ impl Net {
for n in &graph.nodes {
check(n, &graph)?;
}
Ok(Net { graph })
Ok(Net { graph, accelerator: RwLock::new(None) })
}
/// The accelerator in use, if any.
pub fn accelerator(&self) -> Option<Arc<dyn Accelerator>> {
self.accelerator.read().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Use `accelerator` for this net's convolutions, but only after it gave the CPU's outputs on `probe` (every
/// output value within `tolerance` of the CPU's, relative to that output's largest magnitude) and, with
/// `require_faster`, ran it faster than the CPU did (a GPU's per-layer round trip can cost more than a small
/// model's layers). Returns why it was not used, so the caller can say so; the net then stays on the CPU.
pub fn attach_checked(
&self,
accelerator: Arc<dyn Accelerator>,
probe: &Tensor,
tolerance: f32,
require_faster: bool,
) -> std::result::Result<(), String> {
self.detach();
let started = web_time::Instant::now();
let cpu = self.run(probe.clone()).map_err(|e| e.to_string())?;
let cpu_time = started.elapsed();
*self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = Some(accelerator);
// the first run also uploads the weights and builds the kernels: time a second one
let fast = self.run(probe.clone()).map_err(|e| e.to_string());
let mut verdict = match fast {
Err(e) => Err(e),
Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()),
Ok(fast) => agree(&cpu, &fast, tolerance),
};
if verdict.is_ok() && require_faster {
let started = web_time::Instant::now();
let again = self.run(probe.clone()).map_err(|e| e.to_string());
let fast_time = started.elapsed();
verdict = match again {
Err(e) => Err(e),
Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()),
Ok(_) if fast_time >= cpu_time => Err(format!(
"the device is not faster for this model ({:.0} ms against {:.0} ms on the CPU)",
fast_time.as_secs_f64() * 1000.0,
cpu_time.as_secs_f64() * 1000.0
)),
Ok(_) => Ok(()),
};
}
if verdict.is_err() {
self.detach();
}
verdict
}
/// Run on the CPU only from now on.
pub fn detach(&self) {
*self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = None;
}
/// A convolution on the accelerator, if one is attached and takes it. A failing accelerator is dropped.
fn accelerated_conv(&self, layer: usize, node: &Node, x: &Tensor) -> Result<Option<Tensor>> {
let Some(accelerator) = self.accelerator() else { return Ok(None) };
let job = kernels::conv_job(layer, node, &x.shape, &x.data, &self.graph)?;
match accelerator.conv(&job) {
Ok(Some(out)) if out.len() == job.cout * job.oh * job.ow => Ok(Some(Tensor::new(vec![1, job.cout, job.oh, job.ow], out)?)),
Ok(None) => Ok(None),
Ok(Some(_)) | Err(_) => {
self.detach();
Ok(None)
}
}
}
/// The shape the model declares for its input (0 for a dynamic size).
@@ -96,7 +221,67 @@ impl Net {
/// Run the network on `input` (its single input); returns each output by name.
pub fn run(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> {
shape::tensor(&input.shape, input.data.len(), &input.data)?;
self.run_batch(vec![input])?.pop().ok_or_else(|| NetError::Shape("the run gave nothing".into()))
}
/// Run the network on each of `inputs`; an accelerator that keeps the values on its device runs inputs of one shape
/// together (one upload and one read-back for all of them).
pub fn run_batch(&self, inputs: Vec<Tensor>) -> Result<Vec<Vec<(String, Tensor)>>> {
for input in &inputs {
shape::tensor(&input.shape, input.data.len(), &input.data)?;
}
if let Some(out) = self.run_on_device(&inputs)? {
return Ok(out);
}
inputs.into_iter().map(|input| self.run_one(input)).collect()
}
/// The whole batch on the attached accelerator, or `None` to run it on the CPU path. A failing or misbehaving
/// accelerator is dropped.
fn run_on_device(&self, inputs: &[Tensor]) -> Result<Option<Vec<Vec<(String, Tensor)>>>> {
let (Some(accelerator), Some(first)) = (self.accelerator(), inputs.first()) else { return Ok(None) };
if inputs.iter().any(|t| t.shape != first.shape) {
return Ok(None);
}
let Some(program) = self.program(&first.shape)? else { return Ok(None) };
let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect();
let started = web_time::Instant::now();
let outs = match accelerator.run_program(&program, &slices) {
Ok(Some(outs)) => outs,
Ok(None) => return Ok(None),
Err(_) => {
self.detach();
return Ok(None);
}
};
let gathered: Option<Vec<Vec<(String, Tensor)>>> = (outs.len() == inputs.len())
.then(|| {
outs.into_iter()
.map(|values| {
(values.len() == program.outputs.len())
.then(|| {
values
.into_iter()
.zip(&program.outputs)
.map(|(data, (name, _, shape))| Tensor::new(shape.clone(), data).ok().map(|t| (name.clone(), t)))
.collect::<Option<Vec<_>>>()
})
.flatten()
})
.collect()
})
.flatten();
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!("[faces-gpu] {} steps x {}: {:.3} ms", program.steps.len(), inputs.len(), started.elapsed().as_secs_f64() * 1000.0);
}
if gathered.is_none() {
// wrong sizes or values that are not finite: the device is not trusted any more
self.detach();
}
Ok(gathered)
}
fn run_one(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> {
self.validate_input(&input.shape)?;
let mut scratch = kernels::Scratch::default();
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
@@ -127,7 +312,13 @@ impl Net {
};
let started = web_time::Instant::now();
let out = match node.op.as_str() {
"Conv" => kernels::conv(node, &*get(0)?, g, &mut scratch)?,
"Conv" => {
let x = get(0)?;
match self.accelerated_conv(i, node, &x)? {
Some(out) => out,
None => kernels::conv(node, &x, g, &mut scratch)?,
}
}
"Relu" => map(&*get(0)?, |v| v.max(0.0)),
"Sigmoid" => map(&*get(0)?, |v| 1.0 / (1.0 + (-v).exp())),
"Add" | "Sub" | "Mul" => kernels::binary(node.op.as_str(), &*get(0)?, &*get(1)?)?,
@@ -168,6 +359,25 @@ impl Net {
}
}
/// Whether two runs' outputs agree: same names and shapes, values within `tolerance` of each other relative to the
/// output's largest magnitude.
fn agree(a: &[(String, Tensor)], b: &[(String, Tensor)], tolerance: f32) -> std::result::Result<(), String> {
if a.len() != b.len() {
return Err("the device gave a different number of outputs".into());
}
for ((name, x), (other, y)) in a.iter().zip(b) {
if name != other || x.shape != y.shape {
return Err(format!("the device gave `{other}` a different shape"));
}
let scale = x.data.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6);
let worst = x.data.iter().zip(&y.data).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
if worst.is_nan() || worst > tolerance * scale {
return Err(format!("the device's `{name}` differs from the CPU's by {:.2e} (of {scale:.2e})", worst));
}
}
Ok(())
}
fn map(x: &Tensor, f: impl Fn(f32) -> f32) -> Tensor {
Tensor { shape: x.shape.clone(), data: x.data.iter().map(|v| f(*v)).collect() }
}
@@ -534,6 +744,244 @@ mod tests {
assert_eq!(rs.shape, vec![1, 6, 2]);
}
/// An accelerator built on the slow reference convolution, optionally wrong or failing.
struct Fake {
skew: f32,
fail_after: std::sync::atomic::AtomicUsize,
}
impl Accelerator for Fake {
fn device(&self) -> String {
"test".into()
}
fn conv(&self, j: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> {
if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 {
return Err("device lost".into());
}
assert!(j.pt == j.pl && j.sh == j.sw);
let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap();
let zero = vec![0.0; j.cout];
let dims = [j.cout, j.cin / j.groups, j.kh, j.kw];
let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups);
Ok(Some(out.data.iter().map(|v| v + self.skew).collect()))
}
}
fn two_convs() -> (Graph, Tensor) {
let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))];
let mut a = node("Conv", &["x", "w1", "b1"], &attrs);
a.outputs = vec!["h".into()];
let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]);
let mut g = graph(
vec![a, b],
vec![
("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())),
("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])),
("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())),
],
);
g.inputs = vec![("x".into(), vec![1, 3, 9, 8])];
(g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1))
}
#[test]
fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() {
let (g, x) = two_convs();
let net = Net::new(g).unwrap();
let cpu = net.run(x.clone()).unwrap();
let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() });
net.attach_checked(good, &x, 1e-4, false).unwrap();
assert!(net.accelerator().is_some());
assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape);
let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() });
assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err());
assert!(net.accelerator().is_none(), "a device that disagrees is not used");
assert_eq!(net.run(x).unwrap(), cpu);
}
#[test]
fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() {
let (g, x) = two_convs();
let net = Net::new(g).unwrap();
let cpu = net.run(x.clone()).unwrap();
// agrees on the probe (two convs), then fails on the second conv of the next run
net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap();
let out = net.run(x).unwrap();
assert!(net.accelerator().is_none());
for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) {
assert!((a - b).abs() < 1e-4);
}
}
/// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls;
/// `poison` makes it give a value that is not finite.
struct ProgramFake {
calls: std::sync::atomic::AtomicUsize,
poison: bool,
}
fn run_steps(p: &program::Program, input: &[f32]) -> Vec<Vec<f32>> {
use program::{Arg, Binary, Op, Unary};
let mut values: Vec<Vec<f32>> = vec![Vec::new(); p.lens.len()];
values[program::INPUT] = input.to_vec();
for step in &p.steps {
let arg = |i: usize| -> Vec<f32> {
match &step.args[i] {
Arg::Value(v) => values[*v].clone(),
Arg::Const { data, .. } => data.to_vec(),
}
};
let len = p.lens[step.out];
let x = arg(0);
let out: Vec<f32> = match &step.op {
Op::Conv(j) => {
let (m, cg) = (j.cout / j.groups, j.cin / j.groups);
let mut out = vec![0f32; len];
for co in 0..j.cout {
for oy in 0..j.oh {
for ox in 0..j.ow {
let mut v = j.bias.map_or(0.0, |b| b[co]);
for ci in 0..cg {
for ky in 0..j.kh {
for kx in 0..j.kw {
let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize);
if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w {
v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx]
* x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize];
}
}
}
}
out[(co * j.oh + oy) * j.ow + ox] = v;
}
}
}
out
}
Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(),
Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(),
Op::ChannelAffine { channels, plane } => {
let (s, o) = (arg(1), arg(2));
x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect()
}
Op::ChannelPrelu { channels, plane } => {
let a = arg(1);
x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect()
}
Op::Binary { op, dims, a, b } => {
let y = arg(1);
(0..len)
.map(|i| {
let (mut rest, mut ia, mut ib) = (i, 0, 0);
for d in (0..4).rev() {
let at = rest % dims[d];
rest /= dims[d];
ia += at * a[d];
ib += at * b[d];
}
match op {
Binary::Add => x[ia] + y[ib],
Binary::Sub => x[ia] - y[ib],
Binary::Mul => x[ia] * y[ib],
}
})
.collect()
}
Op::Transpose { dims, strides } => (0..len)
.map(|i| {
let (mut rest, mut from) = (i, 0);
for d in (0..4).rev() {
from += (rest % dims[d]) * strides[d];
rest /= dims[d];
}
x[from]
})
.collect(),
Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len)
.map(|i| {
let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh));
let mut m = f32::NEG_INFINITY;
for ky in 0..*kh {
for kx in 0..*kw {
m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]);
}
}
m
})
.collect(),
Op::Resize { h, w, sh, sw, .. } => (0..len)
.map(|i| {
let (ow, oh) = (w * sw, h * sh);
let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh));
x[(c * h + oy / sh) * w + ox / sw]
})
.collect(),
Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::<f32>() / *plane as f32).collect(),
Op::Gemv { k, n, trans_b, alpha } => {
let (wt, bias) = (arg(1), arg(2));
(0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::<f32>() + bias[o]).collect()
}
};
assert_eq!(out.len(), len, "{:?}", step.op);
values[step.out] = out;
for f in &step.free {
values[*f] = Vec::new();
}
}
p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect()
}
impl Accelerator for ProgramFake {
fn device(&self) -> String {
"program test".into()
}
fn conv(&self, _: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> {
Ok(None)
}
fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut out: Vec<Vec<Vec<f32>>> = inputs.iter().map(|x| run_steps(p, x)).collect();
if self.poison {
out[0][0][0] = f32::NAN;
}
Ok(Some(out))
}
}
fn every_operator_input(seed: usize) -> Tensor {
input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05)
}
#[test]
fn a_program_of_every_operator_gives_what_the_interpreter_gives() {
let net = Net::new(crate::synthetic::every_operator_graph()).unwrap();
let x = every_operator_input(0);
let cpu = net.run(x.clone()).unwrap();
let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false });
net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap();
let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed);
assert!(calls > 0, "the whole network ran as one program");
// several inputs go to the device together, and each gets its own outputs
let inputs: Vec<Tensor> = (0..3).map(every_operator_input).collect();
let together = net.run_batch(inputs.clone()).unwrap();
assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch");
net.detach();
for (x, got) in inputs.into_iter().zip(&together) {
let want = net.run(x).unwrap();
agree(&want, got, 1e-5).unwrap();
}
assert_eq!(cpu.len(), 2);
}
#[test]
fn a_device_that_gives_values_that_are_not_finite_is_dropped() {
let net = Net::new(crate::synthetic::every_operator_graph()).unwrap();
let x = every_operator_input(1);
let cpu = net.run(x.clone()).unwrap();
assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err());
assert!(net.accelerator().is_none());
assert_eq!(net.run(x).unwrap(), cpu);
}
#[test]
fn unsupported_and_inconsistent_models_are_errors() {
let x = input(1, 3, 3, |i| i as f32);
+28 -1
View File
@@ -59,6 +59,33 @@ fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, p
}
Ok(())
}
/// The checked geometry of a convolution node, for an accelerator.
pub(super) fn conv_job<'a>(layer: usize, n: &Node, shape: &[usize], input: &'a [f32], g: &'a Graph) -> Result<ConvJob<'a>> {
let (cin, h, w) = program::chw_of(shape)?;
let wt = shape::weight(n, g, 1)?;
let weights = shape::floats(n, g, 1)?;
let [cout, cg, kh, kw] = wt.dims.as_slice() else {
return shape_err("invalid convolution weights");
};
let (cout, cg, kh, kw) = (*cout, *cg, *kh, *kw);
let groups = usize::try_from(n.int("group").unwrap_or(1)).map_err(|_| NetError::Shape("invalid group".into()))?;
let (sh, sw) = pair(n, "strides", 1)?;
let (pt, pl, pb, pr) = pads(n)?;
let ph = h.checked_add(pt).and_then(|v| v.checked_add(pb)).ok_or_else(|| NetError::Shape("padding overflow".into()))?;
let pw = w.checked_add(pl).and_then(|v| v.checked_add(pr)).ok_or_else(|| NetError::Shape("padding overflow".into()))?;
if ph < kh || pw < kw || groups == 0 || cg == 0 || kh == 0 || kw == 0 || cin != groups * cg || cout % groups != 0 {
return shape_err("invalid convolution shape");
}
if cout.checked_mul(cg).and_then(|v| v.checked_mul(kh * kw)) != Some(weights.len()) {
return shape_err("short convolution weights");
}
let bias = if n.inputs.get(2).is_some_and(|s| !s.is_empty()) { Some(shape::floats(n, g, 2)?) } else { None };
if bias.is_some_and(|b| b.len() != cout) {
return shape_err("the convolution bias does not match");
}
let (oh, ow) = ((ph - kh) / sh + 1, (pw - kw) / sw + 1);
Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input })
}
pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result<Tensor> {
let (cin, h, w) = chw(x)?;
let wt = shape::weight(n, g, 1)?;
@@ -181,7 +208,7 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re
Ok(out)
}
/// Flat offset under ONNX's trailing-dimension broadcasting; shapes are checked before execution.
fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize {
pub(super) fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize {
let mut offset = 0;
let mut stride = 1;
for (axis, &size) in out.iter().enumerate().rev() {
+315
View File
@@ -0,0 +1,315 @@
//! A network lowered to a flat list of steps over numbered values, for an [`Accelerator`] that keeps every activation
//! on its device between layers (one upload, one read-back per run instead of one per convolution).
//!
//! Shapes are resolved for one input shape; every step names the values it reads and the one it writes, and which
//! values nothing reads after it. Shape-only operators (`Reshape`, `Flatten`, `Identity`, `Dropout`) make no step: their
//! output is the same value under another name. Anything this cannot express (a broadcast past four dimensions, a
//! transposed or batched `Gemm`, a slope that is not per channel) makes the whole lowering `None`, and the net runs
//! the CPU path instead.
use std::borrow::Cow;
use super::*;
/// Value 0 is the network's input.
pub const INPUT: usize = 0;
/// An operand: a value computed on the device, or a constant (uploaded once and kept, by `id`).
#[derive(Clone, Debug)]
pub enum Arg<'a> {
Value(usize),
/// `id` is the same for every run of the same net, so the device can keep the data.
Const {
id: usize,
data: Cow<'a, [f32]>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unary {
Relu,
Sigmoid,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Binary {
Add,
Sub,
Mul,
}
/// What a step computes. Element counts come from the program's `lens` of the step's output.
#[derive(Clone, Debug)]
pub enum Op<'a> {
/// A convolution; its `input` is empty (it reads the step's first argument).
Conv(ConvJob<'a>),
/// `out[i] = f(x[i])`. Args: `[x]`.
Unary(Unary),
/// `out[i] = x[i] * scale[c] + offset[c]`, `c = (i / plane) % channels`. Args: `[x, scale, offset]`.
ChannelAffine { channels: usize, plane: usize },
/// `out[i] = x[i] < 0 ? x[i] * slope[c] : x[i]`. Args: `[x, slope]` (`channels` values).
ChannelPrelu { channels: usize, plane: usize },
/// Broadcasting over four dimensions: `dims` of the output, element strides of each operand (0 where it repeats).
/// Args: `[a, b]`.
Binary { op: Binary, dims: [usize; 4], a: [usize; 4], b: [usize; 4] },
/// `out[n] = alpha · Σk x[k] · B(k, n) + bias[n]`, `B(k, n) = weights[n·k_len + k]` when `trans_b`, else
/// `weights[k·n_len + n]`. Args: `[x, weights, bias]` (bias already times beta; zeros when the node has none).
Gemv { k: usize, n: usize, trans_b: bool, alpha: f32 },
/// The mean of each `plane`-sized run. Args: `[x]`.
GlobalAverage { channels: usize, plane: usize },
/// Unpadded max pooling. Args: `[x]`.
MaxPool { c: usize, h: usize, w: usize, kh: usize, kw: usize, sh: usize, sw: usize, oh: usize, ow: usize },
/// Nearest-neighbour upscale by whole numbers. Args: `[x]`.
Resize { c: usize, h: usize, w: usize, sh: usize, sw: usize },
/// `out` in its own order (`dims`), read from the input at `strides` (the input's strides, permuted). Args: `[x]`.
Transpose { dims: [usize; 4], strides: [usize; 4] },
}
#[derive(Clone, Debug)]
pub struct Step<'a> {
pub op: Op<'a>,
pub args: Vec<Arg<'a>>,
pub out: usize,
/// Values nothing reads after this step (never an output): their memory can be reused.
pub free: Vec<usize>,
}
/// A whole network for one input shape.
#[derive(Clone, Debug)]
pub struct Program<'a> {
/// Elements of each value; `lens[INPUT]` is the input's.
pub lens: Vec<usize>,
pub steps: Vec<Step<'a>>,
/// Each graph output: its name, the value that holds it and its shape.
pub outputs: Vec<(String, usize, Vec<usize>)>,
}
/// Leading ones up to rank four, or `None` past four.
fn rank4(s: &[usize]) -> Option<[usize; 4]> {
let pad = 4usize.checked_sub(s.len())?;
let mut out = [1; 4];
for (i, d) in s.iter().enumerate() {
*out.get_mut(pad + i)? = *d;
}
Some(out)
}
/// Element strides of `input` read as `out` under trailing-dimension broadcasting (0 along repeated axes).
fn broadcast_strides(out: [usize; 4], input: &[usize]) -> Option<[usize; 4]> {
let dims = rank4(input)?;
let mut strides = [0; 4];
let mut step = 1;
for axis in (0..4).rev() {
let (d, o) = (*dims.get(axis)?, *out.get(axis)?);
if d == o && d != 1 {
*strides.get_mut(axis)? = step;
} else if d != 1 {
return None;
}
step *= d;
}
Some(strides)
}
impl Net {
/// This network lowered for `input` (its shape), or `None` when a step has no device form.
pub fn program(&self, input: &[usize]) -> Result<Option<Program<'_>>> {
let g = &self.graph;
let (shapes, _) = shape::infer_all(g, input)?;
let Some((input_name, _)) = g.inputs.first() else { return shape_err("the model has no input") };
let mut lens = vec![shape::elements(input)?];
let mut names: HashMap<&str, (usize, Vec<usize>)> = HashMap::new();
names.insert(input_name.as_str(), (INPUT, input.to_vec()));
let mut steps: Vec<Step> = Vec::new();
for (i, node) in g.nodes.iter().enumerate() {
let Some(out_shape) = shapes.get(i) else { return shape_err("a node has no shape") };
let Some(out_name) = node.outputs.first() else { return shape_err("a node has no output") };
let value = |idx: usize| -> Option<&(usize, Vec<usize>)> { node.inputs.get(idx).and_then(|n| names.get(n.as_str())) };
let constant = |idx: usize| -> Option<(Cow<'_, [f32]>, &[usize])> {
let w = node.inputs.get(idx).and_then(|n| g.weights.get(n))?;
match &w.data {
Data::F32(v) => Some((Cow::Borrowed(v.as_slice()), w.dims.as_slice())),
_ => None,
}
};
let id = |k: usize| i * 4 + k;
let Some((x, x_shape)) = value(0).cloned() else { return Ok(None) };
let (op, args) = match node.op.as_str() {
"Reshape" | "Flatten" | "Identity" | "Dropout" => {
names.insert(out_name.as_str(), (x, out_shape.clone()));
continue;
}
"Conv" => (Op::Conv(kernels::conv_job(i, node, &x_shape, &[], g)?), vec![Arg::Value(x)]),
"Relu" => (Op::Unary(Unary::Relu), vec![Arg::Value(x)]),
"Sigmoid" => (Op::Unary(Unary::Sigmoid), vec![Arg::Value(x)]),
"BatchNormalization" => {
let channels = *x_shape.get(1).ok_or_else(|| NetError::Shape("BatchNormalization rank".into()))?;
let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?;
let (s, b, m, v) =
(shape::floats(node, g, 1)?, shape::floats(node, g, 2)?, shape::floats(node, g, 3)?, shape::floats(node, g, 4)?);
let eps = shape::float(node, "epsilon", 1e-5);
let mut scale = Vec::with_capacity(channels);
let mut offset = Vec::with_capacity(channels);
for c in 0..channels {
let (Some(s), Some(b), Some(m), Some(v)) = (s.get(c), b.get(c), m.get(c), v.get(c)) else {
return shape_err("short BatchNormalization parameters");
};
let k = s / (v + eps).sqrt();
scale.push(k);
offset.push(b - m * k);
}
(
Op::ChannelAffine { channels, plane },
vec![Arg::Value(x), Arg::Const { id: id(1), data: scale.into() }, Arg::Const { id: id(2), data: offset.into() }],
)
}
"PRelu" => {
let Some((slope, dims)) = constant(1) else { return Ok(None) };
let channels = x_shape.get(1).copied().unwrap_or(1);
let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?;
let mut expanded = vec![1; x_shape.len().saturating_sub(dims.len())];
expanded.extend_from_slice(dims);
let per_channel = expanded.iter().enumerate().all(|(i, &d)| d == 1 || i == 1) && slope.len() == channels;
let slope: Cow<[f32]> = if per_channel {
slope
} else if let [one] = slope.as_ref() {
vec![*one; channels].into()
} else {
return Ok(None);
};
(Op::ChannelPrelu { channels, plane }, vec![Arg::Value(x), Arg::Const { id: id(1), data: slope }])
}
"Add" | "Sub" | "Mul" => {
let op = match node.op.as_str() {
"Add" => Binary::Add,
"Sub" => Binary::Sub,
_ => Binary::Mul,
};
let (b, b_shape) = match (value(1), constant(1)) {
(Some((v, s)), _) => (Arg::Value(*v), s.clone()),
(None, Some((data, dims))) => (Arg::Const { id: id(1), data }, dims.to_vec()),
_ => return Ok(None),
};
let Some(dims) = rank4(out_shape) else { return Ok(None) };
let (Some(sa), Some(sb)) = (broadcast_strides(dims, &x_shape), broadcast_strides(dims, &b_shape)) else { return Ok(None) };
(Op::Binary { op, dims, a: sa, b: sb }, vec![Arg::Value(x), b])
}
"Gemm" => {
let Some((weights, wdims)) = constant(1) else { return Ok(None) };
let ([1, k], [n_out]) = (x_shape.as_slice(), out_shape.get(1..).unwrap_or(&[])) else { return Ok(None) };
if node.int("transA").unwrap_or(0) != 0 || out_shape.first() != Some(&1) {
return Ok(None);
}
let trans_b = node.int("transB").unwrap_or(0) != 0;
if wdims.len() != 2 || weights.len() != k * n_out {
return shape_err("Gemm weights do not match");
}
let mut bias = vec![0.0f32; *n_out];
if node.inputs.get(2).is_some_and(|s| !s.is_empty()) {
let wt = shape::weight(node, g, 2)?;
let b = shape::floats(node, g, 2)?;
let beta = shape::float(node, "beta", 1.0);
for (j, to) in bias.iter_mut().enumerate() {
*to = beta
* b.get(kernels::broadcast_offset(j, out_shape, &wt.dims))
.copied()
.ok_or_else(|| NetError::Shape("short matrix bias".into()))?;
}
}
(
Op::Gemv { k: *k, n: *n_out, trans_b, alpha: shape::float(node, "alpha", 1.0) },
vec![Arg::Value(x), Arg::Const { id: id(1), data: weights }, Arg::Const { id: id(2), data: bias.into() }],
)
}
"GlobalAveragePool" => {
let (c, h, w) = chw_of(&x_shape)?;
(Op::GlobalAverage { channels: c, plane: h * w }, vec![Arg::Value(x)])
}
"MaxPool" => {
let (c, h, w) = chw_of(&x_shape)?;
let (kh, kw) = pair(node, "kernel_shape", 1)?;
let (sh, sw) = pair(node, "strides", 1)?;
if pads(node)? != (0, 0, 0, 0) {
return Ok(None);
}
let (_, oh, ow) = chw_of(out_shape)?;
(Op::MaxPool { c, h, w, kh, kw, sh, sw, oh, ow }, vec![Arg::Value(x)])
}
"Resize" => {
let (c, h, w) = chw_of(&x_shape)?;
let (_, oh, ow) = chw_of(out_shape)?;
if oh % h != 0 || ow % w != 0 {
return shape_err("a Resize that is not by whole numbers");
}
(Op::Resize { c, h, w, sh: oh / h, sw: ow / w }, vec![Arg::Value(x)])
}
"Transpose" => {
let rank = x_shape.len();
let default_perm: Vec<i64> = (0..rank as i64).rev().collect();
let perm = node.ints("perm").unwrap_or(&default_perm);
if rank > 4 || perm.len() != rank {
return Ok(None);
}
let mut in_strides = vec![1usize; rank];
for a in (0..rank.saturating_sub(1)).rev() {
let next = in_strides.get(a + 1).copied().unwrap_or(1) * x_shape.get(a + 1).copied().unwrap_or(1);
if let Some(s) = in_strides.get_mut(a) {
*s = next;
}
}
let Some(dims) = rank4(out_shape) else { return Ok(None) };
let mut strides = [0usize; 4];
for (j, p) in perm.iter().enumerate() {
let p = usize::try_from(*p).map_err(|_| NetError::Shape("a negative permutation".into()))?;
let s = in_strides.get(p).copied().ok_or_else(|| NetError::Shape("a bad permutation".into()))?;
*strides.get_mut(4 - rank + j).ok_or_else(|| NetError::Shape("a bad permutation".into()))? = s;
}
(Op::Transpose { dims, strides }, vec![Arg::Value(x)])
}
_ => return Ok(None),
};
let out = lens.len();
lens.push(shape::elements(out_shape)?);
names.insert(out_name.as_str(), (out, out_shape.clone()));
steps.push(Step { op, args, out, free: Vec::new() });
}
let outputs = g
.outputs
.iter()
.map(|name| {
let (v, s) = names.get(name.as_str()).ok_or_else(|| NetError::Shape(format!("output `{name}` was never computed")))?;
Ok((name.clone(), *v, s.clone()))
})
.collect::<Result<Vec<_>>>()?;
// the last step that reads each value; a value nothing reads is free right after it is made
let mut last: Vec<Option<usize>> = vec![None; lens.len()];
for (at, step) in steps.iter().enumerate() {
if let Some(l) = last.get_mut(step.out) {
*l = Some(at);
}
for a in &step.args {
if let Arg::Value(v) = a
&& let Some(l) = last.get_mut(*v)
{
*l = Some(at);
}
}
}
for (v, at) in last.iter().enumerate() {
if outputs.iter().any(|(_, o, _)| *o == v) {
continue;
}
if let Some(step) = at.and_then(|at| steps.get_mut(at)) {
step.free.push(v);
}
}
Ok(Some(Program { lens, steps, outputs }))
}
}
pub(super) fn chw_of(s: &[usize]) -> Result<(usize, usize, usize)> {
match s {
[1, c, h, w] => Ok((*c, *h, *w)),
s => unsupported(format!("a tensor of shape {s:?} (only [1, channels, height, width])")),
}
}
+9 -1
View File
@@ -257,7 +257,12 @@ fn image(s: &[usize]) -> Result<(usize, usize, usize)> {
}
}
pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
Ok(infer_all(g, input)?.1)
}
/// Each node's output shape, and the graph's outputs' shapes.
pub(super) fn infer_all(g: &Graph, input: &[usize]) -> Result<(Vec<Vec<usize>>, Vec<Vec<usize>>)> {
elements(input)?;
let mut per_node = Vec::with_capacity(g.nodes.len());
let mut shapes: HashMap<&str, Vec<usize>> = g.weights.iter().map(|(n, w)| (n.as_str(), w.dims.clone())).collect();
shapes.insert(g.inputs.first().ok_or_else(|| NetError::Shape("no input".into()))?.0.as_str(), input.to_vec());
let mut live = elements(input)?;
@@ -480,6 +485,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
.checked_add(out_count)
.filter(|&n| n <= 128 * 1024 * 1024)
.ok_or_else(|| NetError::Shape("live tensors exceed the 512 MiB working limit".into()))?;
per_node.push(out.clone());
shapes.insert(n.outputs.first().ok_or_else(|| NetError::Shape("no output".into()))?, out);
let mut removed = std::collections::HashSet::new();
for name in &n.inputs {
@@ -488,5 +494,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
}
}
}
g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect()
let outputs =
g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect::<Result<_>>()?;
Ok((per_node, outputs))
}
+93 -8
View File
@@ -40,6 +40,9 @@ pub struct SelfTest {
type Plan = Net;
/// Most faces [`Embedder::embed_batch`] sends to a GPU in one go.
pub const BATCH: usize = 16;
/// A loaded recognition model.
#[derive(Clone)]
pub struct Embedder {
@@ -99,6 +102,31 @@ impl Embedder {
})
}
/// The network, e.g. to see which device it runs on.
pub fn net(&self) -> &Net {
&self.plan
}
/// Run the model's convolutions on `accelerator` if it gives the CPU's embedding on a test face (and, with
/// `require_faster`, runs it faster than the CPU); otherwise (or on
/// its first error) recognition stays on the CPU. Returns why it was not used.
pub fn accelerate(&self, accelerator: Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), RuntimeError> {
let (mean, std) = (self.manifest.input.mean, self.manifest.input.std);
let plane = self.width * self.height;
let data: Vec<f32> = crate::yunet::probe_pixels(3 * plane)
.into_iter()
.enumerate()
.map(|(i, v)| {
let c = (i / plane.max(1)).min(2);
(v - mean.get(c).copied().unwrap_or(0.0)) / std.get(c).copied().filter(|s| *s != 0.0).unwrap_or(1.0)
})
.collect();
let probe = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?;
guarded("checking the device", || {
self.plan.attach_checked(accelerator, &probe, crate::yunet::ACCELERATOR_TOLERANCE, require_faster).map_err(RuntimeError::Run)
})
}
/// The size of the aligned face picture the model wants (width, height).
pub fn input_size(&self) -> (usize, usize) {
(self.width, self.height)
@@ -110,6 +138,66 @@ impl Embedder {
/// The face as a unit-length vector. `rgb` is an aligned face at exactly [`Self::input_size`], 8-bit RGB.
pub fn embed(&self, rgb: &[u8]) -> Result<Vec<f32>, RuntimeError> {
let input = self.input(rgb)?;
let out = guarded("running it", || {
let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?;
if result.len() != 1 {
return Err(RuntimeError::Run("the model must give one embedding".into()));
}
Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data)
})?;
self.unit(out)
}
/// Several faces at once, each as [`Self::embed`] gives it. On a GPU they run together, up to [`BATCH`] per
/// dispatch (one upload and one read-back for all of them); on the CPU one after another.
pub fn embed_batch(&self, faces: &[&[u8]]) -> Vec<Result<Vec<f32>, RuntimeError>> {
if self.plan.accelerator().is_none() || faces.len() < 2 {
return faces.iter().map(|f| self.embed(f)).collect();
}
let mut results: Vec<Option<Result<Vec<f32>, RuntimeError>>> = faces.iter().map(|_| None).collect();
let mut ready: Vec<(usize, Tensor)> = Vec::new();
for (i, face) in faces.iter().enumerate() {
match self.input(face) {
Ok(t) => ready.push((i, t)),
Err(e) => {
if let Some(r) = results.get_mut(i) {
*r = Some(Err(e));
}
}
}
}
while !ready.is_empty() {
let chunk: Vec<(usize, Tensor)> = ready.drain(..ready.len().min(BATCH)).collect();
let (at, inputs): (Vec<usize>, Vec<Tensor>) = chunk.into_iter().unzip();
let ran = guarded("running it", || self.plan.run_batch(inputs).map_err(|e| RuntimeError::Run(e.to_string())));
match ran {
Ok(outs) if outs.len() == at.len() => {
for (i, mut out) in at.into_iter().zip(outs) {
let r = match (out.len(), out.pop()) {
(1, Some((_, t))) => self.unit(t.data),
_ => Err(RuntimeError::Run("the model must give one embedding".into())),
};
if let Some(slot) = results.get_mut(i) {
*slot = Some(r);
}
}
}
// one bad face spoils a batch: run them one by one so the others still get theirs
_ => {
for i in at {
if let (Some(slot), Some(face)) = (results.get_mut(i), faces.get(i)) {
*slot = Some(self.embed(face));
}
}
}
}
}
results.into_iter().map(|r| r.unwrap_or_else(|| Err(RuntimeError::Run("the face was not embedded".into())))).collect()
}
/// The model's input for an aligned face.
fn input(&self, rgb: &[u8]) -> Result<Tensor, RuntimeError> {
if rgb.len() != self.width * self.height * 3 {
return Err(RuntimeError::Input("the face picture is not the size the model wants".into()));
}
@@ -124,14 +212,11 @@ impl Embedder {
}
}
}
let out = guarded("running it", || {
let input = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?;
let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?;
if result.len() != 1 {
return Err(RuntimeError::Run("the model must give one embedding".into()));
}
Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data)
})?;
Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))
}
/// The model's output, checked and scaled to unit length.
fn unit(&self, out: Vec<f32>) -> Result<Vec<f32>, RuntimeError> {
if out.len() != self.dim {
return Err(RuntimeError::Run(format!("the model gave {} numbers per face, not the {} its description says", out.len(), self.dim)));
}
+60
View File
@@ -248,3 +248,63 @@ pub fn tiny_detector_manifest() -> crate::manifest::ModelManifest {
m.size_bytes = Some(bytes.len() as u64);
m
}
/// A small network that uses every operator the interpreter runs (both kinds of convolution, batch norm, PRelu,
/// sigmoid, broadcasting add/sub/mul with values and constants, max pooling, nearest upscaling, transposes, reshape,
/// global pooling, flatten and a fully connected layer), with two outputs, for checking an accelerator's program
/// against the CPU. Its input is `[1, 4, 8, 8]`.
pub fn every_operator_graph() -> crate::graph::Graph {
use crate::graph::{Attr, Data, Graph, Node, Weight};
let node = |op: &str, inputs: &[&str], output: &str, attrs: &[(&str, Attr)]| Node {
op: op.into(),
inputs: inputs.iter().map(|s| s.to_string()).collect(),
outputs: vec![output.into()],
attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
};
let wave = |n: usize, k: usize| -> Vec<f32> { (0..n).map(|i| (((i * k) % 23) as f32 - 11.0) * 0.04).collect() };
let w = |dims: &[usize], v: Vec<f32>| Weight { dims: dims.to_vec(), data: Data::F32(v) };
let weights = [
("w1", w(&[8, 4, 3, 3], wave(288, 7))),
("b1", w(&[8], wave(8, 5))),
("bn_s", w(&[8], (0..8).map(|i| 0.5 + i as f32 * 0.1).collect())),
("bn_b", w(&[8], wave(8, 3))),
("bn_m", w(&[8], wave(8, 11))),
("bn_v", w(&[8], (0..8).map(|i| 0.2 + i as f32 * 0.05).collect())),
("slope", w(&[8, 1, 1], (0..8).map(|i| 0.05 * i as f32).collect())),
("w2", w(&[8, 1, 3, 3], wave(72, 13))),
("scales", w(&[4], vec![1.0, 1.0, 2.0, 2.0])),
("half", w(&[1], vec![0.5])),
("per_channel", w(&[1, 8, 1, 1], wave(8, 17))),
("wg", w(&[5, 8], wave(40, 19))),
("bg", w(&[5], wave(5, 2))),
("flat", Weight { dims: vec![2], data: Data::I64(vec![1, -1]) }),
];
let pads = |p: i64| ("pads", Attr::Ints(vec![p; 4]));
let nodes = vec![
node("Conv", &["x", "w1", "b1"], "c1", &[pads(1)]),
node("BatchNormalization", &["c1", "bn_s", "bn_b", "bn_m", "bn_v"], "bn", &[]),
node("PRelu", &["bn", "slope"], "pr", &[]),
node("Conv", &["pr", "w2"], "c2", &[("group", Attr::Int(8)), ("strides", Attr::Ints(vec![2, 2])), pads(1)]),
node("Sigmoid", &["c2"], "sg", &[]),
node("Mul", &["c2", "sg"], "swish", &[]),
node("Resize", &["swish", "", "scales"], "up", &[]),
node("Add", &["up", "pr"], "sum", &[]),
node("Sub", &["sum", "half"], "less", &[]),
node("MaxPool", &["less"], "pool", &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))]),
node("Relu", &["pool"], "relu", &[]),
node("Transpose", &["relu"], "nhwc", &[("perm", Attr::Ints(vec![0, 2, 3, 1]))]),
node("Transpose", &["nhwc"], "nchw", &[("perm", Attr::Ints(vec![0, 3, 1, 2]))]),
node("Mul", &["nchw", "per_channel"], "scaled", &[]),
node("Identity", &["scaled"], "same", &[]),
node("GlobalAveragePool", &["same"], "avg", &[]),
node("Flatten", &["avg"], "row", &[]),
node("Reshape", &["row", "flat"], "row2", &[]),
node("Gemm", &["row2", "wg", "bg"], "y", &[("transB", Attr::Int(1)), ("alpha", Attr::Float(0.5))]),
];
Graph {
nodes,
weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
inputs: vec![("x".into(), vec![1, 4, 8, 8])],
outputs: vec!["y".into(), "nhwc".into()],
}
}
+22
View File
@@ -60,6 +60,14 @@ pub struct Face {
pub landmarks: [(f32, f32); 5],
}
/// How far an accelerator's outputs may stray from the CPU's on the test input, relative to each output's largest value.
pub const ACCELERATOR_TOLERANCE: f32 = 1e-3;
/// A deterministic, photo-like test input (smooth gradients plus texture, in 0..255).
pub fn probe_pixels(n: usize) -> Vec<f32> {
(0..n).map(|i| ((i % 640) as f32 * 0.3 + ((i * 7919) % 97) as f32 + (i / 640 % 480) as f32 * 0.2) % 255.0).collect()
}
pub struct Detector {
net: Net,
/// Side of the square network input.
@@ -90,6 +98,20 @@ impl Detector {
Ok(Detector { net, side })
}
/// The network, e.g. to see which device it runs on.
pub fn net(&self) -> &Net {
&self.net
}
/// Run the network's convolutions on `accelerator` if it gives the CPU's outputs on a test picture (and, with `require_faster`,
/// runs it faster than the CPU); otherwise
/// (or on its first error) detection stays on the CPU. Returns why it was not used.
pub fn accelerate(&self, accelerator: std::sync::Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), String> {
let side = self.side;
let probe = Tensor::new(vec![1, 3, side, side], probe_pixels(3 * side * side)).map_err(|e| e.to_string())?;
self.net.attach_checked(accelerator, &probe, ACCELERATOR_TOLERANCE, require_faster)
}
/// The square input side, used to check an installed manifest.
pub fn input_side(&self) -> usize {
self.side
+1
View File
@@ -14,6 +14,7 @@ lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
lightcraft-develop = { workspace = true }
lightcraft-faces = { workspace = true }
lightcraft-pipeline = { workspace = true }
log = { workspace = true }
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -35,6 +35,8 @@ pub mod backend;
mod ctx;
#[cfg(not(target_arch = "wasm32"))]
mod exit;
#[cfg(not(target_arch = "wasm32"))]
pub mod faces;
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
pub mod nn;
#[cfg(not(target_arch = "wasm32"))]
@@ -239,9 +241,9 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool {
}
}
/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the
/// Why GPU compute that has a device of its own (denoise, faces) is switched off by the user's settings, or `None`: the
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn switched_off() -> Option<String> {
if env_disabled() {
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
+171
View File
@@ -0,0 +1,171 @@
// Convolution for the face networks (lightcraft-faces), planar (NCHW) float32, batch 1, any stride, padding and grouping.
//
// `tiled` is an implicit matrix product per group: rows are the group's output channels (M), columns its output
// pixels (N), and K runs over input channel x kernel tap. The input is gathered (im2col) straight into workgroup
// memory, so the lowered matrix is never made. 256 threads make a 64 x 64 block, 4 x 4 values each.
// `direct` gives one output value per thread, for depthwise convolutions (one output channel per group), where a
// 64-row tile would be almost all waste.
//
// out[co][p] = bias[co] + sum_k W[co][k] * X[k][p] X[k][p] = input[g*cg + k/taps][oy*sh + ky - pt][ox*sw + kx - pl]
struct P {
m: u32, // output channels per group
n: u32, // output pixels (oh * ow)
k: u32, // cg * kh * kw
cg: u32, // input channels per group
h: u32,
w: u32,
ow: u32,
kh: u32,
kw: u32,
sh: u32,
sw: u32,
pt: u32,
pl: u32,
bias: u32, // 1 when there is a bias
total: u32, // cout * n
row: u32, // workgroups along x, for `direct` and `reduce` (so a large layer can use y too)
splits: u32, // K is cut into this many slices (z = group * splits + slice), summed by `reduce`
kchunk: u32, // K values per slice, a multiple of BK
_a: u32,
_b: u32,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> src: array<f32>;
@group(0) @binding(2) var<storage, read> wgt: array<f32>;
@group(0) @binding(3) var<storage, read> bias: array<f32>;
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
const BM: u32 = 64u;
const BN: u32 = 64u;
const BK: u32 = 16u;
var<workgroup> As: array<f32, 1024>; // [BK][BM]
var<workgroup> Bs: array<f32, 1024>; // [BK][BN]
fn gather(g: u32, k: u32, pix: u32) -> f32 {
let taps = p.kh * p.kw;
let ci = g * p.cg + k / taps;
let tap = k % taps;
let oy = pix / p.ow;
let ox = pix % p.ow;
let iy = i32(oy * p.sh + tap / p.kw) - i32(p.pt);
let ix = i32(ox * p.sw + tap % p.kw) - i32(p.pl);
if (iy < 0 || ix < 0 || iy >= i32(p.h) || ix >= i32(p.w)) {
return 0.0;
}
return src[(ci * p.h + u32(iy)) * p.w + u32(ix)];
}
@compute @workgroup_size(256)
fn tiled(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let n0 = wg.x * BN;
let m0 = wg.y * BM;
let g = wg.z / p.splits;
let slice = wg.z % p.splits;
let k_lo = slice * p.kchunk;
let k_hi = min(p.k, k_lo + p.kchunk);
let tx = tid % 16u;
let ty = tid / 16u;
var acc: array<array<f32, 4>, 4>;
for (var i = 0u; i < 4u; i++) {
for (var j = 0u; j < 4u; j++) {
acc[i][j] = 0.0;
}
}
for (var k0 = k_lo; k0 < k_hi; k0 += BK) {
// 1024 values of each tile, 4 per thread; every slot has exactly one writer
for (var e = tid; e < 1024u; e += 256u) {
// weights: neighbouring threads read neighbouring k of one row
let mm = e / BK;
let ka = k0 + e % BK;
let m = m0 + mm;
var a = 0.0;
if (ka < k_hi && m < p.m) {
a = wgt[(g * p.m + m) * p.k + ka];
}
As[(e % BK) * BM + mm] = a;
// input: neighbouring threads read neighbouring pixels
let kk = e / BN;
let k = k0 + kk;
let pix = n0 + e % BN;
var b = 0.0;
if (k < k_hi && pix < p.n) {
b = gather(g, k, pix);
}
Bs[e] = b;
}
workgroupBarrier();
for (var kk = 0u; kk < BK; kk++) {
var a: array<f32, 4>;
var b: array<f32, 4>;
for (var i = 0u; i < 4u; i++) {
a[i] = As[kk * BM + ty * 4u + i];
b[i] = Bs[kk * BN + tx + 16u * i];
}
for (var i = 0u; i < 4u; i++) {
for (var j = 0u; j < 4u; j++) {
acc[i][j] += a[i] * b[j];
}
}
}
workgroupBarrier();
}
for (var i = 0u; i < 4u; i++) {
let m = m0 + ty * 4u + i;
if (m >= p.m) {
continue;
}
let co = g * p.m + m;
var b0 = 0.0;
if (p.bias != 0u && p.splits == 1u) {
b0 = bias[co];
}
// with slices, each writes a partial sum of its own: [slice][cout][n]
let base = slice * p.total + co * p.n;
for (var j = 0u; j < 4u; j++) {
// columns tx, tx + 16, tx + 32, tx + 48: neighbouring threads write neighbouring pixels
let pix = n0 + tx + 16u * j;
if (pix < p.n) {
dst[base + pix] = acc[i][j] + b0;
}
}
}
}
@compute @workgroup_size(256)
fn direct(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let at = (wg.y * p.row + wg.x) * 256u + tid;
if (at >= p.total) {
return;
}
let co = at / p.n;
let pix = at % p.n;
let g = co / p.m;
var v = 0.0;
if (p.bias != 0u) {
v = bias[co];
}
for (var k = 0u; k < p.k; k++) {
v += wgt[co * p.k + k] * gather(g, k, pix);
}
dst[at] = v;
}
// The sum of the K slices' partial results, plus the bias. `src` holds [splits][cout][n].
@compute @workgroup_size(256)
fn reduce(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let at = (wg.y * p.row + wg.x) * 256u + tid;
if (at >= p.total) {
return;
}
var v = 0.0;
if (p.bias != 0u) {
v = bias[at / p.n];
}
for (var s = 0u; s < p.splits; s++) {
v += src[s * p.total + at];
}
dst[at] = v;
}
+188
View File
@@ -0,0 +1,188 @@
// The face networks' other operators (lightcraft-faces `net::program`), so activations stay on the device between layers.
// Same bindings as faces_conv.wgsl: the parameters, three read-only operands and the output. Element kernels run one
// thread per output value; `gavg` and `gemv` one workgroup per output value.
//
// Parameters are 24 numbers: q(0) = values (or workgroups) to make, q(1) = workgroups along x, then per kernel.
struct P {
v: array<vec4<u32>, 6>,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> a: array<f32>;
@group(0) @binding(2) var<storage, read> b: array<f32>;
@group(0) @binding(3) var<storage, read> c: array<f32>;
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
fn q(i: u32) -> u32 {
return p.v[i / 4u][i % 4u];
}
fn index(wid: vec3<u32>, lid: u32) -> u32 {
return (wid.y * q(1) + wid.x) * 256u + lid;
}
// q(2): 0 relu, 1 sigmoid
@compute @workgroup_size(256)
fn unary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let x = a[i];
if (q(2) == 0u) {
dst[i] = max(x, 0.0);
} else {
dst[i] = 1.0 / (1.0 + exp(-x));
}
}
// q(2) plane, q(3) channels: x * b[c] + c[c]
@compute @workgroup_size(256)
fn affine(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let ch = (i / q(2)) % q(3);
dst[i] = a[i] * b[ch] + c[ch];
}
// q(2) plane, q(3) channels: x < 0 ? x * b[c] : x
@compute @workgroup_size(256)
fn prelu(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let x = a[i];
let ch = (i / q(2)) % q(3);
dst[i] = select(x, x * b[ch], x < 0.0);
}
// q(2): 0 add, 1 sub, 2 mul; q(3..7) output dims; q(7..11) a's strides; q(11..15) b's strides
@compute @workgroup_size(256)
fn binary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
var rest = i;
var ia = 0u;
var ib = 0u;
for (var d = 3; d >= 0; d = d - 1) {
let du = u32(d);
let size = q(3u + du);
let at = rest % size;
rest = rest / size;
ia = ia + at * q(7u + du);
ib = ib + at * q(11u + du);
}
let x = a[ia];
let y = b[ib];
if (q(2) == 0u) {
dst[i] = x + y;
} else if (q(2) == 1u) {
dst[i] = x - y;
} else {
dst[i] = x * y;
}
}
// q(3..7) output dims; q(7..11) the input's strides for each output axis
@compute @workgroup_size(256)
fn transpose(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
var rest = i;
var at_in = 0u;
for (var d = 3; d >= 0; d = d - 1) {
let du = u32(d);
let size = q(3u + du);
at_in = at_in + (rest % size) * q(7u + du);
rest = rest / size;
}
dst[i] = a[at_in];
}
// q(2) h, q(3) w, q(4) kh, q(5) kw, q(6) sh, q(7) sw, q(8) oh, q(9) ow
@compute @workgroup_size(256)
fn maxpool(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let h = q(2);
let w = q(3);
let oh = q(8);
let ow = q(9);
let ox = i % ow;
let oy = (i / ow) % oh;
let ch = i / (ow * oh);
var m = -3.402823e38;
for (var ky = 0u; ky < q(4); ky = ky + 1u) {
for (var kx = 0u; kx < q(5); kx = kx + 1u) {
let y = oy * q(6) + ky;
let x = ox * q(7) + kx;
if (y < h && x < w) {
m = max(m, a[(ch * h + y) * w + x]);
}
}
}
dst[i] = m;
}
// q(2) h, q(3) w, q(4) sh, q(5) sw: nearest upscale by whole numbers
@compute @workgroup_size(256)
fn resize(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let h = q(2);
let w = q(3);
let ow = w * q(5);
let oh = h * q(4);
let ox = i % ow;
let oy = (i / ow) % oh;
let ch = i / (ow * oh);
dst[i] = a[(ch * h + oy / q(4)) * w + ox / q(5)];
}
var<workgroup> partial: array<f32, 256>;
fn reduce(lid: u32) {
for (var s = 128u; s > 0u; s = s >> 1u) {
workgroupBarrier();
if (lid < s) {
partial[lid] = partial[lid] + partial[lid + s];
}
}
workgroupBarrier();
}
// one workgroup per channel; q(2) plane
@compute @workgroup_size(256)
fn gavg(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let ch = wid.y * q(1) + wid.x;
let plane = q(2);
var sum = 0.0;
if (ch < q(0)) {
for (var j = lid; j < plane; j = j + 256u) {
sum = sum + a[ch * plane + j];
}
}
partial[lid] = sum;
reduce(lid);
if (lid == 0u && ch < q(0)) {
dst[ch] = partial[0] / f32(plane);
}
}
// one workgroup per output; q(2) k, q(3) n, q(4) 1 when B is [n][k], q(5) alpha's bits
@compute @workgroup_size(256)
fn gemv(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let o = wid.y * q(1) + wid.x;
let k = q(2);
let n = q(3);
var sum = 0.0;
if (o < q(0)) {
for (var j = lid; j < k; j = j + 256u) {
let wi = select(j * n + o, o * k + j, q(4) == 1u);
sum = sum + a[j] * b[wi];
}
}
partial[lid] = sum;
reduce(lid);
if (lid == 0u && o < q(0)) {
dst[o] = bitcast<f32>(q(5)) * partial[0] + c[o];
}
}
+1 -1
View File
@@ -285,7 +285,7 @@
"Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknutím na fotografii vyberete barvu; ⇧-kliknutím přidáte další (nejvýše 5)",
"Click to clear these filters": "Kliknutím zrušíte tyto filtry",
"Click to restore · double-click to rename": "Kliknutím obnovíte · dvojklikem přejmenujete",
"Click to type a value": "Kliknutím můžete zadat hodnotu",
"Click to type a value, drag to change it": "Kliknutím můžete zadat hodnotu, tažením ji změníte",
"Clone": "Klonovat",
"Close": "Zavřít",
"Close Panel": "Zavřít panel",
+11 -2
View File
@@ -1945,7 +1945,7 @@
"commercial use subject to GPL conditions": "kommerzielle Nutzung unter GPL-Bedingungen",
"Decrease Rating": "Bewertung verringern",
"Increase Rating": "Bewertung erhöhen",
"Click to type a value": "Klicken, um einen Wert einzugeben",
"Click to type a value, drag to change it": "Klicken, um einen Wert einzugeben, ziehen, um ihn zu ändern",
"Angle": "Winkel",
"Video": "Video",
"Copyrighted": "Urheberrechtlich geschützt",
@@ -2332,5 +2332,14 @@
"Animal Eye Tracking": "Tieraugen-Tracking",
"Human Eye Tracking": "Augen-Tracking (Mensch)",
"Read Focus Points": "Fokuspunkte lesen",
"Reading focus points": "Fokuspunkte werden gelesen"
"Reading focus points": "Fokuspunkte werden gelesen",
"Click to type a value": "Klicken, um einen Wert einzugeben",
"Run face models on the CPU only": "Gesichtsmodelle nur auf der CPU ausführen",
"Detection": "Erkennung",
"Recognition": "Wiedererkennung",
"Not loaded yet": "Noch nicht geladen",
"Checking the GPU…": "GPU wird geprüft …",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.",
"Checking the GPU for faces": "GPU für Gesichter wird geprüft"
}
+11 -2
View File
@@ -1969,7 +1969,7 @@
"Increase Rating": "Aumentar valoración",
"Index Faces": "Indexar caras",
"Index Faces in the Background": "Indexar caras en segundo plano",
"Click to type a value": "Hacer clic para escribir un valor",
"Click to type a value, drag to change it": "Hacer clic para escribir un valor, arrastrar para cambiarlo",
"Angle": "Ángulo",
"Video": "Vídeo",
"Copyrighted": "Con copyright",
@@ -2331,5 +2331,14 @@
"Animal Eye Tracking": "Seguimiento de ojos de animales",
"Human Eye Tracking": "Seguimiento de ojos humanos",
"Read Focus Points": "Leer puntos de enfoque",
"Reading focus points": "Leyendo puntos de enfoque"
"Reading focus points": "Leyendo puntos de enfoque",
"Click to type a value": "Hacer clic para escribir un valor",
"Run face models on the CPU only": "Ejecutar los modelos de caras solo en la CPU",
"Detection": "Detección",
"Recognition": "Reconocimiento",
"Not loaded yet": "Aún no cargado",
"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"
}
+11 -2
View File
@@ -1779,7 +1779,7 @@
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Appliquer le mappage local des tons enregistré dans le fichier raw (Apple ProRAW), comme le rend l’appareil",
"Decrease Rating": "Diminuer la note",
"Increase Rating": "Augmenter la note",
"Click to type a value": "Cliquez pour saisir une valeur",
"Click to type a value, drag to change it": "Cliquez pour saisir une valeur, faites glisser pour la modifier",
"Angle": "Angle",
"Video": "Vidéo",
"Copyrighted": "Protégé par copyright",
@@ -2329,5 +2329,14 @@
"Animal Eye Tracking": "Suivi des yeux d’animaux",
"Human Eye Tracking": "Suivi des yeux humains",
"Read Focus Points": "Lire les points de mise au point",
"Reading focus points": "Lecture des points de mise au point"
"Reading focus points": "Lecture des points de mise au point",
"Click to type a value": "Cliquez pour saisir une valeur",
"Run face models on the CPU only": "Exécuter les modèles de visages sur le processeur uniquement",
"Detection": "Détection",
"Recognition": "Reconnaissance",
"Not loaded yet": "Pas encore chargé",
"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"
}
+11 -2
View File
@@ -1789,7 +1789,7 @@
"commercial use subject to GPL conditions": "商用利用は GPL の条件に従う",
"Decrease Rating": "評価を下げる",
"Increase Rating": "評価を上げる",
"Click to type a value": "クリックして値を入力",
"Click to type a value, drag to change it": "クリックして値を入力、ドラッグして変更",
"Angle": "角度",
"Video": "動画",
"Copyrighted": "著作権あり",
@@ -2331,5 +2331,14 @@
"Animal Eye Tracking": "動物瞳トラッキング",
"Human Eye Tracking": "人物瞳トラッキング",
"Read Focus Points": "フォーカスポイントを読み込む",
"Reading focus points": "フォーカスポイントを読み込み中"
"Reading focus points": "フォーカスポイントを読み込み中",
"Click to type a value": "クリックして値を入力",
"Run face models on the CPU only": "顔モデルを CPU だけで実行",
"Detection": "検出",
"Recognition": "認識",
"Not loaded yet": "未読み込み",
"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 を確認中"
}
+299
View File
@@ -0,0 +1,299 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} რამდენიმე მსგავსი ფაილით: გამოიყენეთ „მოძებნა“)",
" ({} in Recently Deleted)": " ({} ახლახან წაშლილებში)",
" · {:.0}% clipped": " · {:.0}% მოკვეთილია",
" · {failed} couldn't be rendered": " · ვერ დარენდერდა: {failed}",
" · {queued} waiting": " · რიგშია: {queued}",
" · {skipped} skipped (file exists)": " · გამოტოვებულია: {skipped} (ფაილი უკვე არსებობს)",
" · {} duplicate{} skipped": " · გამოტოვებული დუბლიკატი: {}{:.0}",
" · {} failed: {}": " · ვერ მოხერხდა: {}: {}",
" · {} moved": " · გადატანილია: {}",
" · {} not readable": " · ვერ იკითხება: {}",
" · {} original{} left at the source": " · წყაროში დატოვებული ორიგინალი: {}{:.0}",
" · {} restored from Recently Deleted": " · ახლახან წაშლილებიდან აღდგენილია: {}",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One ვერ შემოიტანს სიას, სადაც საკვანძო სიტყვა შეიცავს ; , < ან > სიმბოლოს: {names}",
" — left out, as a keyword list can't hold them: {names}": " — გამოტოვებულია, რადგან საკვანძო სიტყვების სია მათ ვერ დაიტევს: {names}",
" — not carried over: {}{}": " — არ გადავიდა: {}{}",
"+{n} more": "+ კიდევ {n}",
"+{} more": "+ კიდევ {}",
", {failed} file{} not readable": ", ვერ იკითხება ფაილი: {failed}{:.0}",
", {} label": ", იარლიყი: {}",
", {} star{}": ", ვარსკვლავი: {}{:.0}",
"1 task is still running: {names}. Quitting stops it.": "ჯერ კიდევ მიმდინარეობს 1 ამოცანა: {names}. გასვლა მას შეწყვეტს.",
"; {kept} already had a location": "; მდებარეობა უკვე ჰქონდა: {kept}",
"; {n} gained synonyms": "; სინონიმები დაემატა: {n}",
"; {outside} outside its time range": "; მარშრუტის დროის დიაპაზონს გარეთაა: {outside}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, კომიტი: {}, სტრიქონი: +{} / −{} (Δ {}), ბინარული ფაილი: +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "ჩემს ფოტოებში დამატება: {local_n}",
"Add {} Photo{}": "ფოტოს დამატება: {}{:.0}",
"Add {} Photo{}, restore {}": "ფოტოს დამატება: {}{:.0}, აღდგენა: {}",
"Add “{}” to Favorites": "„{}“ რჩეულებში დამატება",
"Added {n} keyword{}": "დაემატა საკვანძო სიტყვა: {n}{:.0}",
"Added {n} photo{} to My Photos": "ჩემს ფოტოებს დაემატა ფოტო: {n}{:.0}",
"Added {n} photo{} to “{}”": "„{1}“-ს დაემატა ფოტო: {n}{0:.0}",
"Added {what} to {name}": "{what}: დაემატა {name}-ს",
"Added “{keyword}” to {n} photo{}": "„{keyword}“ დაემატა ფოტოს: {n}{:.0}",
"Adding photos… {} of {}": "ფოტოების დამატება… {} / {}",
"All {n} selected photos move by the same amount.": "მონიშნული ფოტოები ({n}) ერთი და იმავე სიდიდით გადაინაცვლებს.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "უკვე ბიბლიოთეკაშია, ახლახან წაშლილებში: მონიშნულია ფოტო: {}{:.0} · მარჯვენა დაწკაპუნება → აღდგენა, ან სამუდამოდ წაშლა თავიდან იმპორტისთვის",
"Already in the library: {} photo{} selected in All Photos": "უკვე ბიბლიოთეკაშია: „ყველა ფოტოში“ მონიშნულია ფოტო: {}{:.0}",
"Auto ({})": "ავტომატური ({})",
"Auto Import: added {} photo{}": "ავტოიმპორტი: დაემატა ფოტო: {}{:.0}",
"Auto Sync: edits apply to {n} photos": "ავტოსინქრონიზაცია: ცვლილებები ვრცელდება ფოტოებზე: {n}",
"Automatic ({} MB)": "ავტომატური ({} MB)",
"Build 1:1 Previews ({scope})": "1:1 წინასწარი ხედების შექმნა ({scope})",
"Build Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების შექმნა ({scope})",
"Build Standard-Sized Previews ({scope})": "სტანდარტული ზომის წინასწარი ხედების შექმნა ({scope})",
"Building previews for {} photos…": "იქმნება წინასწარი ხედები ფოტოებისთვის: {}…",
"Click {}:1": "დაწკაპუნება {}:1",
"Couldn't open the editor: {e}": "რედაქტორი ვერ გაიხსნა: {e}",
"Create Keyword Tag Inside “{name}”…": "საკვანძო სიტყვის შექმნა „{name}“-ში…",
"Create {n} Virtual Copies": "ვირტუალური ასლების შექმნა: {n}",
"Creates {} stacks from {} of {scope}": "შეიქმნება დასტა: {} — {}-დან ({scope})",
"Decoder: {reason}": "დეკოდერი: {reason}",
"Delete \"{label}\"": "„{label}“-ის წაშლა",
"Delete \"{label}\" (and the mask)": "„{label}“-ის წაშლა (ნიღაბთან ერთად)",
"Delete the keyword “{name}”?": "წაიშალოს საკვანძო სიტყვა „{name}“?",
"Delete {n} Photos": "ფოტოების წაშლა: {n}",
"Delete “{current}”": "„{current}“-ის წაშლა",
"Denoising {name}: {done} of {total} tiles{}": "{name}-ის ხმაურის მოცილება: ფრაგმენტი {done} / {total}{}",
"Discard Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების მოცილება ({scope})",
"Done ({what}): {done} photo{plural}": "მზადაა ({what}): ფოტო: {done}{plural:.0}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "ჩამოიტვირთება {host}-დან. მიღებისა და შემოწმების შემდეგ LightCraft მას დააყენებს, {after}.",
"Duplicate \"{label}\"": "„{label}“-ის დუბლირება",
"Example: {example}": "მაგალითი: {example}",
"Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}",
"Exported {n} photo{}": "ექსპორტირებულია ფოტო: {n}{:.0}",
"Exported {ok} of {total} photo{}": "ექსპორტირებულია ფოტო: {ok} / {total}{:.0}",
"Exported {} point curve preset{}": "ექსპორტირებულია წერტილოვანი მრუდის პრესეტი: {}{:.0}",
"Exported {} preset{}": "ექსპორტირებულია პრესეტი: {}{:.0}",
"Exporting {} of {total}": "ექსპორტი: {} / {total}",
"Filter bar — {active} active filter{}": "ფილტრების ზოლი — აქტიური ფილტრი: {active}{:.0}",
"Filtered: {label}": "გაფილტრულია: {label}",
"Focus {:.0}": "ფოკუსი {:.0}",
"Folder “{}” (and its subfolders)": "საქაღალდე „{}“ (ქვესაქაღალდეებით)",
"Found {n} missing photo{}; {left} still missing{unsure}": "ნაპოვნია დაკარგული ფოტო: {n}{:.0}; კვლავ აკლია: {left}{unsure}",
"Healed {n} dust spot{}": "აღდგენილია მტვრის ლაქა: {n}{:.0}",
"Import cancelled · {} photo{} added": "იმპორტი გაუქმდა · დაემატა ფოტო: {}{:.0}",
"Import failed: {e}": "იმპორტი ვერ მოხერხდა: {e}",
"Import warnings ({n}):\n{warnings}": "იმპორტის გაფრთხილებები ({n}):\n{warnings}",
"Imported {n} point curve preset{}": "იმპორტირებულია წერტილოვანი მრუდის პრესეტი: {n}{:.0}",
"Imported {n} preset{}": "იმპორტირებულია პრესეტი: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "იმპორტირებულია პრესეტი: {n}{:.0}, ვერ იკითხება ფაილი: {f}{:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "იმპორტირებულია პროფილი: {profiles}{:.0} (პროფილების ბრაუზერი → მათი ჯგუფები)",
"Imported {} photo{}": "იმპორტირებულია ფოტო: {}{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "AI ხმაურის მოცილების რიგში (წინ არის: {ahead})…",
"Insert at the cursor (also written {})": "კურსორის ადგილას ჩასმა (ასევე იწერება როგორც {})",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "მოიხსნება ფოტოებიდან ({count}){:.0} მის ქვემოთ მყოფ საკვანძო სიტყვებთან ერთად. დაბრუნება მას აღადგენს.",
"Label Set: {name}": "იარლიყების ნაკრები: {name}",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ჯერ ვერ დეკოდირებს RAW-ის ამ ვარიანტს ({}). თქვენ არედაქტირებთ კამერის ჩაშენებულ JPEG-ხედს, რომელიც უკვე შეიცავს კამერის გამოსახულების სტილს (მაგ., მონოქრომულს) და თეთრის ბალანსს.",
"LightCraft could not open its window: {e}": "LightCraft-მა ფანჯარა ვერ გახსნა: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft-მა ფანჯარა ვერ გახსნა: {e}\n\nდეტალები ჟურნალის ფაილშია: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-ის კატალოგი შემოწმდა: ფოტო: {photos}, კოლექცია: {collections}, აკლია: {missing}",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Guided Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; შენახული გარდაქმნა იგნორირებულია, რადგან Upright გამორთულია.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; {mode} ხელახლა გამოითვლება.",
"Lightroom import complete: {imported} photos added": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}, ვერ მოხერხდა: {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}; ინდექსის გაფრთხილება: {warning}",
"Lightroom import failed: {e}{warning}": "Lightroom-იდან იმპორტი ვერ მოხერხდა: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Lightroom-ის შემოწმება ვერ მოხერხდა: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. შრიფტები: {} (OFL). ხატულები: ორიგინალური.",
"Making the denoised picture… {}%": "იქმნება ხმაურმოცილებული სურათი… {}%",
"Matches: {}": "დამთხვევა: {}",
"Merging… {stage} {:.0}%": "გაერთიანება… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "გადავიდეს {what} ახლახან წაშლილებში?",
"Moved {} photo(s)": "გადატანილია ფოტო: {}",
"Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}",
"Named ({named_n})": "სახელიანი ({named_n})",
"New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში",
"On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე",
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“-ის მინიჭება: დაწკაპეთ ფოტოებზე მის დასამატებლად ან მოსახსნელად · Esc ასრულებს",
"Paste into {n} photo{} — only settings that were copied are pasted": "ჩასმა ფოტოებში: {n}{:.0} — ჩაისმება მხოლოდ კოპირებული პარამეტრები",
"Preset: {name}": "პრესეტი: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "მხოლოდ წინასწარი ხედი — რედაქტირდება კამერის ჩაშენებული JPEG ({})",
"Preview only — {}": "მხოლოდ წინასწარი ხედი — {}",
"Previews ready for {done} photo{plural}": "წინასწარი ხედები მზადაა ფოტოებისთვის: {done}{plural:.0}",
"Profile: {}": "პროფილი: {}",
"Proof Copy ({})": "საცდელი ასლი ({})",
"Proof Preview · {}": "საცდელი ჩვენება · {}",
"Put inside “{parent}”": "„{parent}“-ში მოთავსება",
"Quick Develop · {n} photos": "სწრაფი დამუშავება · ფოტო: {n}",
"Quick Develop: {name}": "სწრაფი დამუშავება: {name}",
"Rated {}": "შეფასება: {}",
"Reading Lightroom catalog… {done} of {total}": "იკითხება Lightroom-ის კატალოგი… {done} / {total}",
"Reading photos… {done} of {total}": "იკითხება ფოტოები… {done} / {total}",
"Reading photos… {} of {}": "იკითხება ფოტოები… {} / {}",
"Redo {}": "{}-ის გამეორება",
"Remove “{name}”": "„{name}“-ის მოშორება",
"Remove “{name}” and its {count} photo{} from the library?": "მოშორდეს „{name}“ და მისი ფოტოები ({count}){:.0} ბიბლიოთეკიდან?",
"Remove “{}” from Favorites": "„{}“ რჩეულებიდან ამოშლა",
"Removed from {}": "ამოიშალა {}-დან",
"Removed {what} from {name}": "{what}: ამოიშალა {name}-დან",
"Rename {n} Photos…": "ფოტოების გადარქმევა: {n}…",
"Rename “{name}”": "„{name}“-ის გადარქმევა",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "„{from}“ გადაერქმევა ფოტოებზე ({n}){:.0} (მის ქვემოთ მყოფ საკვანძო სიტყვებზეც). დონეებისთვის გამოიყენეთ |, მაგ., მოგზაურობა|იტალია. არსებული სახელის შემთხვევაში ორივე გაერთიანდება.",
"Rendering on the CPU: {why}": "რენდერი CPU-ზე: {why}",
"Replace {} with:": "{}-ის ჩანაცვლება:",
"Reset {name}": "ჩამოყრა: {name}",
"Reset {} Channel": "არხის ჩამოყრა: {}",
"Restore {}": "აღდგენა: {}",
"Restore {} Photo{}": "ფოტოს აღდგენა: {}{:.0}",
"Restored {} photo{} from Recently Deleted": "ახლახან წაშლილებიდან აღდგა ფოტო: {}{:.0}",
"Running on the processor: {}.": "მუშაობს პროცესორზე: {}.",
"Running on {}.": "მუშაობს: {}.",
"Saved export preset “{}”": "ექსპორტის პრესეტი „{}“ შენახულია",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "აფასებს ფოკუსსა და ექსპოზიციას ({scope}) და პოულობს ერთმანეთისგან რამდენიმე წამში გადაღებულ მსგავს კადრებს (სერიებს).",
"Show {hidden} hidden location{}": "დამალული მდებარეობების ჩვენება: {hidden}{:.0}",
"Showing {n} of {}": "ნაჩვენებია {n} / {}",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "აჩვენებს მოდელის პირობებს, შემდეგ ჩამოტვირთავს {}-ს {host}-დან, დააყენებს და დაიწყებს მის გამოყენებას. სხვა არაფერი იგზავნება.",
"Smart album: {}": "სმარტ-ალბომი: {}",
"Tagged {n} photo{} from the tracklog": "მარშრუტის ჟურნალით მოინიშნა ფოტო: {n}{:.0}",
"The SAM 3 download failed: {e}": "SAM 3-ის ჩამოტვირთვა ვერ მოხერხდა: {e}",
"There is a keyword set “{name}” already": "საკვანძო სიტყვების ნაკრები „{name}“ უკვე არსებობს",
"There is a “{name}” at the top level already. Merge the two?": "ზედა დონეზე „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?",
"Trained on: {provenance}": "გაწვრთნილია: {provenance}",
"Undo {}": "დაბრუნება: {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "უცნობი ტეგი{s:.0} {} რჩება როგორც აიკრიფა{:.0} (იხ. „ტეგები“)",
"Updated “{name}”": "„{name}“ განახლდა",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "ვერსია {} — სუფთა ოთახის მეთოდით შექმნილი, სუფთა Rust-ზე დაწერილი ფოტობიბლიოთეკა და RAW-დამმუშავებელი.",
"Version: {}": "ვერსია: {}",
"Virtual copy “{name}” of {of}": "{of}-ის ვირტუალური ასლი „{name}“",
"Working on {what} for {} photos…": "{what}: მუშავდება ფოტოები ({})…",
"XMP adjustments were not imported for {path}: {fields}.": "{path}-ისთვის XMP-ის კორექციები არ შემოვიდა: {fields}.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "{path}-ისთვის XMP პროფილი „{profile}“ არ გამოიყენება; ფერები შეიძლება განსხვავდებოდეს Lightroom-ისგან.",
"none (CPU rendering): {why}": "არცერთი (რენდერი CPU-ზე): {why}",
"not added to Apple Photos: {e}": "არ დაემატა Apple Photos-ს: {e}",
"the {n} selected photos": "მონიშნული ფოტოები ({n})",
"the {} photos in view": "ხედში არსებული ფოტოები ({})",
"these {count} photos": "ეს ფოტოები ({count})",
"{:.1} MB": "{:.1} MB",
"{count} smart preview{} · {:.1} MB": "ჭკვიანი წინასწარი ხედი: {count}{:.0} · {:.1} MB",
"{done} of {total}": "{done} / {total}",
"{done} of {total} MB": "{done} / {total} MB",
"{file} · {size}": "{file} · {size}",
"{f} folders (and their subfolders)": "საქაღალდე: {f} (ქვესაქაღალდეებით)",
"{label} Grading": "გრადაცია: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} ყველა მონიშნულ ფოტოზე",
"{label} {index} of {n}": "{label} {index} / {n}",
"{label}, {n} photos": "{label}, ფოტო: {n}",
"{matching} of {t} photos": "ფოტო: {matching} / {t}",
"{matching} photos": "ფოტო: {matching}",
"{mode} spot {} of {}": "{mode}: ლაქა {} / {}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}.{year}",
"{msg} ({failed} failed)": "{msg} (ვერ მოხერხდა: {failed})",
"{name} Label": "იარლიყი: {name}",
"{name} Off": "{name}: გამორთულია",
"{name} On": "{name}: ჩართულია",
"{name} opened for editing; it is stacked with the original": "{name} გაიხსნა რედაქტირებისთვის; ორიგინალთან ერთ დასტაშია",
"{name} opened in the other editor": "{name} გაიხსნა სხვა რედაქტორში",
"{name} · {terms}": "{name} · {terms}",
"{name} — double-click to reset it": "{name} — ორმაგი დაწკაპუნებით ჩამოყრა",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "გამოტოვებულია ლოკალური ფოტო: {n}{0:.0} — ალბომებში მხოლოდ ბიბლიოთეკის ფოტოებია (ჯერ დაამატეთ ჩემს ფოტოებში)",
"{n} added to Apple Photos": "დაემატა Apple Photos-ს: {n}",
"{n} added to Apple Photos album “{album}”": "დაემატა Apple Photos-ის ალბომს „{album}“: {n}",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "დისკზე ვერ ჩაიწერა ცვლილება: {n}{0:.0}: {e}\nთუ ახლა გახვალთ, ისინი დაიკარგება.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "ცვლილება შენახულია მეხსიერებაში, მაგრამ დისკზე არ ჩაწერილა: {n}{:.0}: {e}\nLightCraft ავტომატურად ცდილობს ხელახლა; ახლა გასვლით ისინი დაიკარგება.{:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "ცვლილება შენახულია მეხსიერებაში, მაგრამ დისკზე არ ჩაწერილა: {n}{:.0}: {e} — LightCraft ხელახლა სცდის",
"{n} found · {sel} selected": "ნაპოვნია: {n} · მონიშნულია: {sel}",
"{n} originals opened in the other editor": "სხვა რედაქტორში გახსნილი ორიგინალები: {n}",
"{n} photos": "ფოტო: {n}",
"{n} photos in {} are hidden by the filters above": "ზემოთ მოცემული ფილტრები მალავს „{}“-ის ფოტოებს: {n}",
"{n} photos opened for editing; each is stacked with its original": "რედაქტირებისთვის გაიხსნა ფოტო: {n}; თითოეული თავის ორიგინალთან ერთ დასტაშია",
"{n} photo{}": "ფოტო: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "შესაბამისი ფოტო: {n}{:.0} · ავტომატურად განახლდება ფოტოების ცვლილებისას",
"{n} photo{} now · updates automatically as photos change": "ახლა ფოტო: {n}{:.0} · ავტომატურად განახლდება ფოტოების ცვლილებისას",
"{n} similar photo{} · View ▸ Clear Filters to see all": "მსგავსი ფოტო: {n}{:.0} · ყველას სანახავად: ხედი → ფილტრების გასუფთავება",
"{n} tasks are still running: {names}. Quitting stops them.": "ჯერ კიდევ მიმდინარეობს ამოცანები ({n}): {names}. გასვლა მათ შეწყვეტს.",
"{n} {noun}": "{noun}: {n}",
"{pct} %": "{pct} %",
"{pid} (missing)": "{pid} (აკლია)",
"{sel_n} selected · {counted}": "მონიშნულია: {sel_n} · {counted}",
"{size} GB": "{size} GB",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} (ფოტო: {n}){:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}",
"{what} merge added": "{what}: გაერთიანება დაემატა",
"{what} merge cancelled": "{what}: გაერთიანება გაუქმდა",
"{what} merge failed: {e}": "{what}: გაერთიანება ვერ მოხერხდა: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "{{date:…}} დირექტივები: {}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"{} and higher": "{} და მეტი",
"{} contributors · {} commits": "მონაწილე: {} · კომიტი: {}",
"{} failed": "ვერ მოხერხდა: {}",
"{} files": "ფაილი: {}",
"{} files and {f} folder{}": "ფაილი: {}, საქაღალდე: {f}{:.0}",
"{} item{}": "ელემენტი: {}{:.0}",
"{} label (click more labels to show any of them)": "იარლიყი „{}“ (დაწკაპეთ სხვა იარლიყებზე, რომ ნებისმიერი მათგანის მქონე ფოტო გამოჩნდეს)",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "დამხმარე ხაზი: {} / 4 — გაავლეთ ხაზებზე, რომლებიც ვერტიკალური ან ჰორიზონტალური უნდა იყოს.",
"{} of {} from {host}": "{} / {} — {host}-დან",
"{} photos": "ფოტო: {}",
"{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}",
"{} sample{}": "ნიმუში: {}{:.0}",
"{} sec": "{} წმ",
"{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. ინახება ბიბლიოთეკის „denoise“ საქაღალდეში; ლიმიტის მიღწევისას პირველად იშლება ყველაზე ძველი, არცერთი ფოტოს მიერ გამოუყენებელი ფაილები.",
"{} — show their photos": "{} — მისი ფოტოების ჩვენება",
"· {dups} already in the library": "· უკვე ბიბლიოთეკაშია: {dups}",
"· {in_source} identical copies": "· იდენტური ასლები: {in_source}",
"· {n} photo{}": "· ფოტო: {n}{:.0}",
"· {trashed} in Recently Deleted": "· ახლახან წაშლილებში: {trashed}",
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“-ში „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?",
"• {assumption}": "• {assumption}",
"… and {more} more": "… და კიდევ {more}",
" in {g} group{}": " in {g} group{}",
" · kept, not applied: {}": " · kept, not applied: {}",
" · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)",
" — kept but not applied: {}": " — kept but not applied: {}",
"Applied {name}": "Applied {name}",
"Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos",
"Create the keyword “{name}”": "Create the keyword “{name}”",
"Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?",
"Delete these {n} effect presets?": "Delete these {n} effect presets?",
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
"Delete “{name}”?": "Delete “{name}”?",
"Effect: {name}": "Effect: {name}",
"Exported to {path}": "Exported to {path}",
"Exported {} effect preset{}": "Exported {} effect preset{}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)",
"Imported {name}": "Imported {name}",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"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.",
"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}",
"Slideshow exported: {what}": "Slideshow exported: {what}",
"Template preview: {name}": "Template preview: {name}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
"Updated {name}": "Updated {name}",
"new, inside {parent}": "new, inside {parent}",
"{count} more warnings.": "{count} more warnings.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
"{n} JPEG files": "{n} JPEG files",
"{n} pages": "{n} pages",
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
"{n} preset{}": "{n} preset{}",
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
"{s} s of AV1 video": "{s} s of AV1 video",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"“{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.",
"…and {more} more": "…and {more} more",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"Already in “{}”": "Already in “{}”",
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1745,7 +1745,7 @@
"commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL",
"Decrease Rating": "Zmniejsz ocenę",
"Increase Rating": "Zwiększ ocenę",
"Click to type a value": "Kliknij, aby wpisać wartość",
"Click to type a value, drag to change it": "Kliknij, aby wpisać wartość, przeciągnij, aby ją zmienić",
"Angle": "Kąt",
"Video": "Wideo",
"Copyrighted": "Chronione prawem autorskim",
+11 -2
View File
@@ -1789,7 +1789,7 @@
"commercial use subject to GPL conditions": "uso comercial sujeito às condições da GPL",
"Decrease Rating": "Diminuir avaliação",
"Increase Rating": "Aumentar avaliação",
"Click to type a value": "Clique para digitar um valor",
"Click to type a value, drag to change it": "Clique para digitar um valor, arraste para alterá-lo",
"Angle": "Ângulo",
"Video": "Vídeo",
"Copyrighted": "Protegido por direitos autorais",
@@ -2171,5 +2171,14 @@
"Animal Eye Tracking": "Rastreamento de olhos de animais",
"Human Eye Tracking": "Rastreamento de olhos humanos",
"Read Focus Points": "Ler pontos de foco",
"Reading focus points": "Lendo pontos de foco"
"Reading focus points": "Lendo pontos de foco",
"Click to type a value": "Clique para digitar um valor",
"Run face models on the CPU only": "Executar os modelos de rostos só na CPU",
"Detection": "Detecção",
"Recognition": "Reconhecimento",
"Not loaded yet": "Ainda não carregado",
"Checking the GPU…": "Verificando a GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.",
"Checking the GPU for faces": "Verificando a GPU para rostos"
}
+11 -2
View File
@@ -1462,7 +1462,7 @@
"commercial use subject to GPL conditions": "коммерческое использование на условиях GPL",
"Decrease Rating": "Уменьшить оценку",
"Increase Rating": "Повысить оценку",
"Click to type a value": "Щёлкните, чтобы ввести значение",
"Click to type a value, drag to change it": "Щёлкните, чтобы ввести значение, или перетащите, чтобы изменить",
"Angle": "Угол",
"Raw": "RAW",
"Video": "Видео",
@@ -2324,5 +2324,14 @@
"Animal Eye Tracking": "Слежение за глазами животных",
"Human Eye Tracking": "Слежение за глазами людей",
"Read Focus Points": "Прочитать точки фокусировки",
"Reading focus points": "Чтение точек фокусировки"
"Reading focus points": "Чтение точек фокусировки",
"Click to type a value": "Щёлкните, чтобы ввести значение",
"Run face models on the CPU only": "Запускать модели лиц только на ЦП",
"Detection": "Обнаружение",
"Recognition": "Распознавание",
"Not loaded yet": "Ещё не загружена",
"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": "Проверка ГП для лиц"
}
+11 -2
View File
@@ -1973,7 +1973,7 @@
"Recognition models": "Моделі розпізнавання",
"Decrease Rating": "Зменшити оцінку",
"Increase Rating": "Збільшити оцінку",
"Click to type a value": "Клацніть, щоб ввести значення",
"Click to type a value, drag to change it": "Клацніть, щоб ввести значення, або перетягніть, щоб змінити",
"Angle": "Кут",
"Video": "Відео",
"Copyrighted": "Захищено авторським правом",
@@ -2329,5 +2329,14 @@
"Animal Eye Tracking": "Стеження за очима тварин",
"Human Eye Tracking": "Стеження за очима людей",
"Read Focus Points": "Прочитати точки фокусування",
"Reading focus points": "Читання точок фокусування"
"Reading focus points": "Читання точок фокусування",
"Click to type a value": "Клацніть, щоб ввести значення",
"Run face models on the CPU only": "Запускати моделі облич лише на ЦП",
"Detection": "Виявлення",
"Recognition": "Розпізнавання",
"Not loaded yet": "Ще не завантажено",
"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": "Перевірка ГП для облич"
}
+11 -2
View File
@@ -1789,7 +1789,7 @@
"commercial use subject to GPL conditions": "商用须遵循 GPL 条款",
"Decrease Rating": "降低星级",
"Increase Rating": "提高星级",
"Click to type a value": "单击以输入数值",
"Click to type a value, drag to change it": "单击以输入数值,拖动以更改",
"Angle": "角度",
"Video": "视频",
"Copyrighted": "受版权保护",
@@ -2170,5 +2170,14 @@
"Animal Eye Tracking": "动物眼部追踪",
"Human Eye Tracking": "人眼追踪",
"Read Focus Points": "读取对焦点",
"Reading focus points": "正在读取对焦点"
"Reading focus points": "正在读取对焦点",
"Click to type a value": "单击以输入数值",
"Run face models on the CPU only": "仅在 CPU 上运行人脸模型",
"Detection": "检测",
"Recognition": "识别",
"Not loaded yet": "尚未加载",
"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"
}
+11 -2
View File
@@ -1789,7 +1789,7 @@
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "降低評分",
"Increase Rating": "提高評分",
"Click to type a value": "點擊以輸入數值",
"Click to type a value, drag to change it": "點擊以輸入數值,拖曳以變更",
"Angle": "角度",
"Video": "影片",
"Copyrighted": "受著作權保護",
@@ -2132,5 +2132,14 @@
"Animal Eye Tracking": "動物眼部追蹤",
"Human Eye Tracking": "人眼追蹤",
"Read Focus Points": "讀取對焦點",
"Reading focus points": "正在讀取對焦點"
"Reading focus points": "正在讀取對焦點",
"Click to type a value": "點擊以輸入數值",
"Run face models on the CPU only": "僅在 CPU 上執行臉孔模型",
"Detection": "偵測",
"Recognition": "辨識",
"Not loaded yet": "尚未載入",
"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"
}
+52
View File
@@ -129,6 +129,7 @@ language_table! {
Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json");
Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json");
It, "it", "Italiano", "Latn", include_str!("../locales/it.json");
Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
@@ -226,6 +227,22 @@ pub fn stopped_message(error: &str, log_file: Option<&str>) -> (String, String)
(tr("LightCraft stopped").to_string(), text)
}
/// Whether sentence case may upper-case `c`. Georgian has no case: upper-casing Mkhedruli would
/// turn it into Mtavruli, a different (all-caps) script, so Georgian letters stay as written.
pub fn capitalizes(c: char) -> bool {
!matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}')
}
/// `text` with its first letter upper-cased ("its input" → "Its input"), unless [`capitalizes`]
/// says that letter has no capital to take.
pub fn upper_first(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) if capitalizes(first) => first.to_uppercase().chain(chars).collect(),
_ => text.to_string(),
}
}
pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
@@ -517,6 +534,15 @@ mod tests {
set_language(Locale::En);
}
#[test]
fn sentence_case_keeps_georgian_lowercase() {
assert_eq!(upper_first("its input is not an image"), "Its input is not an image");
assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება");
assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ");
assert_eq!(upper_first(""), "");
assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ'));
}
#[test]
fn every_language_has_a_catalog_covering_the_source() {
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
@@ -755,6 +781,7 @@ mod tests {
("app.language.polish", Locale::Pl),
("app.language.czech", Locale::Cs),
("app.language.italian", Locale::It),
("app.language.georgian", Locale::Ka),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
@@ -981,6 +1008,31 @@ mod tests {
set_language(Locale::En);
}
#[test]
fn georgian_switches_formats_counts_and_dates() {
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.georgian", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Ka);
assert_eq!(crate::menubar::checked(&app, "app.language.georgian"), Some(true));
assert_eq!(tr("Light"), "სინათლე");
assert_eq!(date_group_label("2026-09-20", false), "კვირა, 20.09.2026");
assert_eq!(date_group_label("2026-09", false), "09.2026");
for n in [0, 1, 2, 5, 21] {
assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("იმპორტირებულია ფოტო: {n}"));
}
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Trip", n = 3), "„Trip“-ს დაემატა ფოტო: 3");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"ka""#));
for code in ["ka", "ka-GE", "ka_GE.UTF-8"] {
assert_eq!(Locale::parse_tag(code), Some(Locale::Ka));
}
// no Mtavruli: Georgian UI text is written in lowercase Mkhedruli
for text in Locale::Ka.catalog().values() {
assert!(!text.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "{text}");
}
set_language(Locale::En);
}
#[test]
fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() {
let ctx = fonts_ctx(&[]);
+2
View File
@@ -152,6 +152,8 @@ mod tests_sam_setup;
#[cfg(test)]
mod tests_scroll;
#[cfg(test)]
mod tests_slider_reset;
#[cfg(test)]
mod tests_slideshow;
#[cfg(test)]
mod tests_solo_sections;
+2
View File
@@ -27,6 +27,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"),
("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"),
("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"),
("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
@@ -52,6 +53,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
"app.language.polish" => Some(crate::i18n::Locale::Pl),
"app.language.czech" => Some(crate::i18n::Locale::Cs),
"app.language.italian" => Some(crate::i18n::Locale::It),
"app.language.georgian" => Some(crate::i18n::Locale::Ka),
_ => None,
}
}
+1 -3
View File
@@ -606,9 +606,7 @@ pub(super) fn mb(bytes: Option<u64>) -> String {
pub(super) fn sentence(s: &str) -> String {
let s = s.trim();
let mut c = s.chars();
let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default();
out.push_str(c.as_str());
let mut out = crate::i18n::upper_first(s);
if !out.is_empty() && !out.ends_with(['.', '!', '?']) {
out.push('.');
}
+20 -13
View File
@@ -402,12 +402,16 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt
let job = if c.interacting { job.draft() } else { job };
let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay };
let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) };
let job = job.with_overlay(overlay);
// the After window is proofed like the whole-frame render under it; the Before side is
// never proofed (its whole-frame render isn't either)
let proof = app.ui.soft_proof.then_some(app.ui.proof);
let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof });
// keyed as the renderer keys the texture (a display profile changes the key)
let job = app.renderer.job_for(v.slot, job);
// The overlay is part of the tile's identity too, so changing mask view/colour never leaves a
// stale region tile on screen while its replacement renders.
let look = job.settings.hash64() ^ overlay.key().rotate_left(17);
// The overlay and the proof are part of the tile's identity too, so changing mask view/colour
// or the proof never leaves a stale region tile on screen while its replacement renders.
let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) };
let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41);
let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look };
// windows of other photos, looks and zooms of this side can't be drawn any more
app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting));
@@ -533,15 +537,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
let look = d.hash64();
// Diagnostic views that need whole-frame context, soft proofing, or reads too far for one
// window still render the whole frame at the size it is drawn. A mask overlay is window-safe.
// Diagnostic views that need whole-frame context, or reads too far for one window, still
// render the whole frame at the size it is drawn. Mask overlays and soft proofing (both act
// pixel by pixel) are window-safe.
let sizes = crate::region::ViewSizes {
drawn_long,
canvas_long: canvas.width().max(canvas.height()) * ppp,
native_long,
texture_side,
draft,
windows: !app.ui.soft_proof && overlay_supports_window(overlay),
windows: overlay_supports_window(overlay),
};
let mut plan = crate::region::plan(&app.ui.settings, sizes);
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
@@ -623,10 +628,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.renderer.request(Slot::Before, job, 90);
}
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use
// the same window; diagnostic overlays that need whole-frame context do not.
let windowable_view =
hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and
// soft proofing use the same window; diagnostic overlays that need whole-frame context do not.
let windowable_view = hover_key.is_none() && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
let window_ctx = WindowCtx {
id,
overlay,
@@ -2170,11 +2174,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
app.brush_live = None;
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
// holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing
let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt);
let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift));
let erase = app.ui.brush_erase != alt;
// a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom
let connect = shift && resp.clicked() && pts.len() == 1;
let _ = app.run(
"mask.brushStroke",
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure}),
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}),
);
}
return;
+38 -3
View File
@@ -164,6 +164,42 @@ pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
}
/// Where the face models run (the GPU only once a check shows it gives the CPU's results faster), and the switch that
/// keeps them on the CPU.
fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Value) {
heading(ui, t, "Processor");
let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false);
let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only"));
register(ui.ctx(), "check:settings.facesCpuOnly", r.rect);
if r.changed() {
match app.run("faces.gpu", json!({"cpuOnly": cpu})) {
Ok(_) => app.caches.faces_epoch += 1,
Err(e) => app.toast(ui.ctx(), e),
}
}
for (what, label) in [("detection", "Detection"), ("recognition", "Recognition")] {
let st = &gpu[what];
let (text, why) = if st.is_null() {
(crate::i18n::tr("Not loaded yet").to_string(), None)
} else if st["checking"] == true {
(crate::i18n::tr("Checking the GPU…").to_string(), None)
} else if let Some(device) = st["device"].as_str() {
(device.to_string(), None)
} else {
(crate::i18n::tr("CPU").to_string(), st["why"].as_str())
};
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim));
let r = ui.label(text);
register(ui.ctx(), format!("faces:device:{what}"), r.rect);
if let Some(why) = why {
r.on_hover_text(sentence(why));
}
});
}
hint(ui, t, "A face model uses the GPU only when a check shows it gives the same results faster.");
}
/// A download-style size: decimal megabytes, as file managers and model pages show them.
fn mb(bytes: Option<u64>) -> String {
match bytes {
@@ -177,9 +213,7 @@ fn mb(bytes: Option<u64>) -> String {
/// "its input is not an image" → "Its input is not an image."
fn sentence(s: &str) -> String {
let s = s.trim();
let mut c = s.chars();
let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default();
out.push_str(c.as_str());
let mut out = crate::i18n::upper_first(s);
if !out.is_empty() && !out.ends_with(['.', '!', '?']) {
out.push('.');
}
@@ -238,6 +272,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default();
let downloads =
app.session.execute("faces.models.downloads", &json!({})).map(|v| v["downloads"].as_array().cloned().unwrap_or_default()).unwrap_or_default();
gpu_section(app, ui, t, &list["gpu"]);
heading(ui, t, "Face detector");
for m in all.iter().filter(|m| m["role"] == "detector") {
model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime);
+2 -2
View File
@@ -621,13 +621,13 @@ mod tests {
"Export Keywords…",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ja", "zh-hans", "zh-hant", "pl", "cs"] {
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs"] {
let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap();
let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap();
for label in labels {
assert!(catalog.contains_key(label), "{code}: “{label}”");
}
}
assert_eq!(Locale::ALL.len(), 13, "a language added: list its file above");
assert_eq!(Locale::ALL.len(), 14, "a language added: list its file above");
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options:
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
row(ui, t, crate::i18n::tr("Language"), |ui| {
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
// Twelve language names do not fit one line in a narrow window: wrap them.
// Thirteen language names do not fit one line in a narrow window: wrap them.
ui.horizontal_wrapped(|ui| {
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
});
+5 -5
View File
@@ -77,8 +77,8 @@ pub struct ViewSizes {
/// `None`: no drag.
pub draft: Option<f32>,
/// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay
/// that needs whole-frame context, soft proofing, or a read too far for one window). Mask
/// overlays are window-safe.
/// that needs whole-frame context, or a read too far for one window). Mask
/// overlays and soft proofing are window-safe.
pub windows: bool,
}
@@ -225,7 +225,7 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long };
let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.clamp(0.1, 1.0) } else { 1.0 });
if !v.windows {
// the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no
// the whole frame at the size it is drawn (a whole-frame diagnostic has no
// window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must
// not render the photo's every pixel each frame
let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long };
@@ -422,7 +422,7 @@ mod tests {
assert_eq!(drag.window_edge, still.window_edge);
}
// Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the
// Given no window (a whole-frame diagnostic), the view at rest renders the
// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the
// windowed path
#[test]
@@ -626,7 +626,7 @@ mod tests {
}
}
// Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a
// Given a mode whose picture can't be a window (a whole-frame diagnostic, or a
// spot read too far for one render), the whole-frame render is as big as the view
#[test]
fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() {
+1 -1
View File
@@ -99,7 +99,7 @@ mod tests {
fn run_dialog(h: &mut crate::headless::Headless) -> Value {
let dialog = h.app.ui.dialog.take().unwrap();
crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap();
let deadline = Instant::now() + Duration::from_secs(120);
let deadline = Instant::now() + Duration::from_secs(300);
let mut seen_row = false;
loop {
h.step();
+1 -1
View File
@@ -121,7 +121,7 @@ fn the_straighten_value_says_it_can_be_typed() {
// tooltips wait for the pointer to rest
let _ = h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t.contains("type")));
assert!(
h.painted_text().iter().any(|t| t == "Click to type a value"),
h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"),
"{:?}",
h.painted_text().iter().filter(|t| t.contains("lick")).collect::<Vec<_>>()
);
+26
View File
@@ -343,6 +343,32 @@ fn holding_the_brush_still_does_not_harden_the_spot() {
h.settle(SETTLE);
}
/// A ⇧-click with the brush paints a straight line from where the last stroke ended (issue #106 of the Classic
/// fork), one undo step; a plain click only paints a dab where it lands.
#[test]
fn shift_click_paints_a_straight_line_from_the_last_stroke() {
let mut h = detail("panel.masking");
exec(&mut h, "tool.brush", json!({}));
brush_at(&mut h, 0.2, 0.5);
brush_at(&mut h, 0.8, 0.5);
assert!(mask_coverage(&h, 0, 50, 50) < 0.01, "two plain clicks leave the space between them alone");
let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.5, "y": 0.2}, {"kind": "up", "x": 0.5, "y": 0.2}], "shift": true}), T);
assert_eq!(r["ok"], true, "{r}");
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert_eq!(strokes.len(), 3, "{strokes:?}");
assert_eq!(strokes[2].points.len(), 2, "from the last stroke's end to the click: {strokes:?}");
for (x, y) in [(65, 35), (58, 27)] {
assert!(mask_coverage(&h, 0, x, y) > 0.5, "the line from (0.8, 0.5) to (0.5, 0.2) passes ({x}, {y})");
}
assert!(mask_coverage(&h, 0, 35, 35) < 0.01, "and nowhere else");
exec(&mut h, "edit.undo", json!({}));
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert_eq!(strokes.len(), 2, "one undo step takes the line back");
h.settle(SETTLE);
}
#[test]
fn brush_strokes_carry_auto_mask() {
let mut h = detail("panel.masking");
+34 -16
View File
@@ -261,6 +261,29 @@ mod in_the_loupe {
assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}");
}
// Given soft proofing on at 1:1 (LR-VIEW-ZOOM), the view is a canvas-sized frame plus a
// native window carrying the proof, as without it, instead of a whole-frame render at the
// photo's full size; switching the proof off swaps the window back to the plain look
#[test]
fn soft_proofing_at_one_to_one_is_a_window() {
let (mut h, native) = detail();
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let plain = h.app.region_view.expect("a window render without the proof");
let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": true, "destWarning": true}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.ui.soft_proof);
let main = rendered_long_edge(&h);
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
let proofed = h.app.region_view.expect("a window render with the proof");
assert_eq!(proofed.full.0.max(proofed.full.1), native, "cut from the photo's own pixels");
assert_ne!(proofed.settings, plain.settings, "the proof is part of the window's look");
h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": false}}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window");
}
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
#[test]
fn an_explicit_limit_still_caps_one_to_one() {
@@ -646,10 +669,16 @@ mod in_the_loupe {
#[test]
#[ignore = "a measurement, not a check"]
fn profile_slider_drag() {
for (size, percent) in
[([1400.0, 900.0], 0.0), ([1400.0, 900.0], 100.0), ([1400.0, 900.0], 400.0), ([2800.0, 1700.0], 100.0), ([2800.0, 1700.0], 400.0)]
{
for (size, percent, proof) in [
([1400.0, 900.0], 0.0, false),
([1400.0, 900.0], 100.0, false),
([1400.0, 900.0], 400.0, false),
([2800.0, 1700.0], 100.0, false),
([2800.0, 1700.0], 400.0, false),
([1400.0, 900.0], 100.0, true),
] {
let (mut h, native) = detail_in(size);
h.app.ui.soft_proof = proof;
h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) };
h.settle(SETTLE);
h.app.session.begin_interaction("Exposure").unwrap();
@@ -673,9 +702,10 @@ mod in_the_loupe {
ms.sort_by(|a, b| a.total_cmp(b));
let (w, hh) = region_tile(&h).unwrap_or((0, 0));
eprintln!(
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit){} of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
size[0],
size[1],
if proof { ", soft proof" } else { "" },
ms[ms.len() / 2],
ms[ms.len() * 9 / 10],
ms[ms.len() - 1],
@@ -819,18 +849,6 @@ mod in_the_loupe {
assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::<Vec<_>>());
}
// Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame
// render is as big as the view, not a canvas-sized upscale
#[test]
fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() {
let (mut h, native) = detail();
h.app.ui.soft_proof = true;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_view, None);
assert_eq!(rendered_long_edge(&h), native);
}
// Given a window the photo's spots make too big for one render (refused), the loupe falls back
// to the whole frame at the drawn size, and tries windows again when the look changes
#[test]
+151
View File
@@ -0,0 +1,151 @@
//! Headless tests of resetting a slider (issue #1436): double-clicking its name or its thumb puts it
//! back to its default in one undo step, as in Lightroom, and resting the pointer on either says so.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// The sliders the issue names, each set away from its default.
const EDITED: [(&str, f64); 2] = [("light.exposure", 1.25), ("light.contrast", 40.0)];
/// The demo library in Detail with the Edit panel's Light section open.
fn edit_panel() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[0].0;
assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true);
assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true);
h.app.ui.open_sections = vec!["light".to_string()];
h.settle(SETTLE);
h
}
fn widget(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
fn value(h: &Headless, control: &str) -> f64 {
let id = h.app.session.selection.active.unwrap();
lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap()
}
fn default(control: &str) -> f64 {
lightcraft_develop::controls::find(control).unwrap().default
}
fn ask(h: &mut Headless, method: &str, params: serde_json::Value) {
let r = h.request(method, params.clone(), T);
assert_eq!(r["ok"], true, "{method} {params}: {r}");
h.settle(SETTLE);
}
fn set(h: &mut Headless, control: &str, value: f64) {
ask(h, "engine.execute", json!({"command": "develop.set", "params": {"control": control, "value": value}}));
}
/// The thumb's place along the track (0..1), plus `dx` points: a hand lands near its centre, not on it.
fn thumb_fx(h: &Headless, control: &str, dx: f32) -> f32 {
let spec = lightcraft_develop::controls::find(control).unwrap();
let track = widget(h, &format!("slider:{control}"));
spec.position(value(h, control)) as f32 + dx / track.width()
}
/// A double-click on a widget: the control channel plays each press and release in a frame of its
/// own, so the slider sees the first click before the second, as with a hand on a mouse.
fn double_click(h: &mut Headless, id: &str, fx: f32) {
ask(h, "ui.clickWidget", json!({"id": id, "count": 2, "fx": fx}));
}
#[test]
fn double_clicking_a_sliders_name_resets_it_in_one_step() {
let mut h = edit_panel();
for (control, edited) in EDITED {
set(&mut h, control, edited);
let undo = h.app.session.undo.len();
double_click(&mut h, &format!("sliderLabel:{control}"), 0.1);
assert_eq!(value(&h, control), default(control), "{control} is back at its default");
assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step");
ask(&mut h, "engine.execute", json!({"command": "edit.undo"}));
assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back");
}
}
/// The first click of the double-click lands a few points off the thumb's centre: it leaves the value
/// alone (it used to move it there, a history step of its own before the reset).
#[test]
fn double_clicking_a_sliders_thumb_resets_it_in_one_step() {
let mut h = edit_panel();
for (control, edited) in EDITED {
set(&mut h, control, edited);
let undo = h.app.session.undo.len();
let fx = thumb_fx(&h, control, 4.0);
double_click(&mut h, &format!("slider:{control}"), fx);
assert_eq!(value(&h, control), default(control), "{control} is back at its default");
assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step");
ask(&mut h, "engine.execute", json!({"command": "edit.undo"}));
assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back");
}
}
/// A click on the thumb leaves the value alone; a click elsewhere on the track still moves it there.
#[test]
fn a_click_on_the_thumb_keeps_the_value() {
let mut h = edit_panel();
set(&mut h, "light.exposure", 1.25);
let fx = thumb_fx(&h, "light.exposure", -4.0);
ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": fx}));
assert_eq!(value(&h, "light.exposure"), 1.25);
ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": 0.25}));
assert!((value(&h, "light.exposure") - -2.5).abs() < 0.02, "{}", value(&h, "light.exposure"));
}
#[test]
fn a_sliders_name_and_thumb_say_a_double_click_resets_it() {
let mut h = edit_panel();
set(&mut h, "light.exposure", 1.25);
let hint = "Exposure — double-click to reset it";
let shown = |h: &Headless| h.painted_text().iter().any(|t| t == hint);
let thumb = thumb_fx(&h, "light.exposure", 0.0);
for (what, id, fx) in [("name", "sliderLabel:light.exposure", 0.1), ("thumb", "slider:light.exposure", thumb)] {
// somewhere else first, so the hint of the step before goes
ask(&mut h, "ui.hoverWidget", json!({"id": "sliderLabel:light.contrast", "fx": 0.9}));
assert!(!shown(&h), "{what}: no hint left over");
ask(&mut h, "ui.hoverWidget", json!({"id": id, "fx": fx}));
// tooltips wait for the pointer to rest
assert!(
h.step_until(Duration::from_secs(5), shown),
"{what}: {:?}",
h.painted_text().iter().filter(|t| t.contains("reset")).collect::<Vec<_>>()
);
}
// the rest of the track is for dragging and clicking: no hint there
ask(&mut h, "ui.hoverWidget", json!({"id": "slider:light.exposure", "fx": 0.1}));
for _ in 0..90 {
h.step();
}
assert!(!shown(&h), "no hint away from the thumb");
// the value keeps its own hint
ask(&mut h, "ui.hoverWidget", json!({"id": "sliderValue:light.exposure"}));
assert!(h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it")));
}
/// Typing a value (KEY-SLIDER-TYPE, #1362) still works next to the reset.
#[test]
fn a_typed_value_still_applies_after_a_reset() {
let mut h = edit_panel();
set(&mut h, "light.exposure", 1.25);
double_click(&mut h, "sliderLabel:light.exposure", 0.1);
assert_eq!(value(&h, "light.exposure"), 0.0);
let undo = h.app.session.undo.len();
ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"}));
for (method, params) in [("ui.text", json!({"text": "0.5"})), ("ui.key", json!({"key": "Enter"}))] {
ask(&mut h, method, params);
}
assert_eq!(value(&h, "light.exposure"), 0.5);
assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step");
}
+84 -12
View File
@@ -218,10 +218,18 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String {
if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }
}
/// Pointer travel (points, sideways from the press) that turns a press on a slider's value into a
/// scrub rather than a click that types a value.
const SCRUB_START: f32 = 3.0;
/// The radius of a slider's hollow ring thumb.
const THUMB_RING: f32 = 7.0;
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Scroll sideways over
/// it to nudge it. Click the value to type one: Return or clicking away applies it, Esc keeps the
/// old one.
/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment.
/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it,
/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth
/// as far).
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
@@ -257,19 +265,48 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
node.add_action(egui::accesskit::Action::Decrement);
}
});
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
// the name: double-click it to reset, as the thumb (a disabled slider's name is only text)
let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() });
// say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do
let reset_hint = crate::i18n::tr_format!("{name} — double-click to reset it", name = label_text);
let label_resp = if enabled { label_resp.on_hover_text(reset_hint.as_str()) } else { label_resp };
crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone()));
register(ui.ctx(), format!("sliderLabel:{}", spec.id), label_rect);
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
// the value: click it to type an exact one (over the label, so it takes the click)
// the value: click it to type an exact one, drag it sideways to scrub (over the label, so it
// takes the click)
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() });
crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value")));
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() });
crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it")));
register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect);
// say that an exact value can be typed here (issue #534: an exact crop angle was hard to find)
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp };
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp };
let typing_id = id.with("typing");
// Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit.
let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id));
// A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead
// of rounding back to the same step every frame.
let scrub_id = id.with("scrub-pos");
let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None };
let mut scrub_started = false;
if enabled
&& scrub.is_none()
&& value_resp.dragged()
&& let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos()))
&& (p.x - origin.x).abs() >= SCRUB_START
{
// a press on the value that moves sideways scrubs: the edit the press opened is dropped
typing = None;
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
// the travel so far counts, so the value doesn't lag the pointer by the start distance
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0));
scrub_started = true;
}
if scrub.is_none() {
ui.data_mut(|m| m.remove::<f64>(scrub_id));
}
let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
// Focus on the owned primary press before fast typing; retain the readout's click activation.
if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) {
@@ -286,8 +323,35 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`)
let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width();
let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width()));
// the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest
// of the track, which is clicked and dragged, gets no tooltip)
let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0;
if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) {
resp.clone().on_hover_text(reset_hint.as_str());
}
let mut v = value;
if let Some((mut text, frames, pressed)) = typing.take() {
if let Some(mut pos) = scrub {
// scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel
// over the track's width (a tenth with Shift), on the control's steps
if !scrub_started && value_resp.dragged() {
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
let dx = ui.input(|i| i.pointer.delta().x);
pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0);
}
let step = spec.step.max(1e-9);
let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max);
if (nv - value).abs() > 1e-12 {
out.value = Some(nv);
v = nv;
}
out.drag_started = scrub_started;
if value_resp.drag_stopped() {
out.drag_stopped = true;
ui.data_mut(|m| m.remove::<f64>(scrub_id));
} else {
ui.data_mut(|m| m.insert_temp(scrub_id, pos));
}
} else if let Some((mut text, frames, pressed)) = typing.take() {
// Keep the owned opening release focused without restarting its draft or selection.
// egui gives that release's click to the readout, so the field under it gives up the focus
// whatever frame it lands on (issue #1362): the field takes it back after drawing too.
@@ -358,6 +422,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
}
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
// a click on the thumb leaves it there, so the first click of a double-click on it isn't a
// history step of its own before the reset
&& !on_thumb(p)
{
let step = spec.step.max(1e-9);
let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max);
@@ -454,11 +521,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c);
if typing.is_none() {
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);
if enabled && value_resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
// the scrub pointer, as over Lightroom's values: a drag here changes the value
if enabled && (value_resp.hovered() || scrub.is_some()) {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
}
let ring = 7.0;
let ring = THUMB_RING;
let tx = to_x(v);
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
let p = ui.painter();
@@ -937,6 +1005,10 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S
#[path = "widgets/tests_numeric_entry.rs"]
mod numeric_entry_tests;
#[cfg(test)]
#[path = "widgets/tests_slider_reset.rs"]
mod slider_reset_tests;
fn focused_slider_key() -> egui::Id {
egui::Id::new("lightcraft-focused-slider")
}
@@ -11,6 +11,7 @@ struct Harness {
enabled: bool,
time: f64,
rect: Rect,
track: Rect,
}
impl Harness {
fn new() -> Self {
@@ -21,6 +22,7 @@ impl Harness {
enabled: true,
time: 0.0,
rect: Rect::NOTHING,
track: Rect::NOTHING,
};
for _ in 0..3 {
h.frame(vec![]);
@@ -33,7 +35,10 @@ impl Harness {
self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| {
out = slider(ui, &self.spec, self.value, self.enabled, None);
});
self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1;
let registry = take_registry(&self.view.ctx);
let find = |name: String| registry.iter().find(|(id, _)| id == &name).map(|(_, r)| *r);
self.rect = find(format!("sliderValue:{}", self.spec.id)).unwrap();
self.track = find(format!("slider:{}", self.spec.id)).unwrap();
if let Some(v) = out.value {
self.value = v;
}
@@ -64,9 +69,10 @@ fn opening_release_honors_return_and_escape_immediately() {
#[test]
fn dragging_away_from_readout_does_not_capture_keyboard() {
// straight down: no sideways travel, so the press neither types nor scrubs
let mut h = Harness::new();
let inside = h.rect.center();
let outside = egui::pos2(5.0, 80.0);
let outside = egui::pos2(inside.x, 80.0);
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] {
let out = h.frame(events);
@@ -94,3 +100,79 @@ fn typing_after_a_released_click_still_edits() {
h.frame(vec![Event::Text("2".into())]);
assert_eq!(h.key(Key::Enter).value, Some(2.0));
}
impl Harness {
/// The track's width: a scrub moves the value as far as dragging the thumb would.
fn track_width(&self) -> f32 {
self.track.width()
}
/// Press on the value, move sideways by `dx` in `steps` frames (Shift held when `shift`) and release;
/// the outputs of every frame.
fn scrub(&mut self, dx: f32, steps: usize, shift: bool) -> Vec<SliderOut> {
let modifiers = if shift { Modifiers::SHIFT } else { Modifiers::NONE };
let start = self.rect.center();
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers };
let mut outs = vec![self.frame(vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(start)]), self.frame(vec![button(start, true)])];
for i in 1..=steps {
let pos = start + vec2(dx * i as f32 / steps as f32, 0.0);
outs.push(self.frame(vec![Event::PointerMoved(pos)]));
}
outs.push(self.frame(vec![button(start + vec2(dx, 0.0), false)]));
outs.push(self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]));
outs
}
}
#[test]
fn dragging_the_value_sideways_scrubs_it_like_the_thumb() {
let mut h = Harness::new();
let width = f64::from(h.track_width());
assert!(width > 100.0, "the slider has a track ({width})");
// a fifth of the track: a fifth of the range (Exposure −5..+5 → +2 EV)
let outs = h.scrub((width / 5.0) as f32, 10, false);
assert!((h.value - 2.71).abs() < 0.015, "scrubbed to {}", h.value);
assert_eq!(outs.iter().filter(|o| o.drag_started).count(), 1, "one interaction begins");
assert_eq!(outs.iter().filter(|o| o.drag_stopped).count(), 1, "and ends, so the scrub is one undo step");
assert!(outs.iter().filter_map(|o| o.value).count() > 1, "the value follows the pointer while it moves");
assert!(!h.view.ctx.egui_wants_keyboard_input(), "a scrub opens no value field");
assert!(h.key(Key::Enter).value.is_none(), "and leaves no draft to commit");
}
#[test]
fn scrubbing_back_and_with_shift() {
let mut h = Harness::new();
let width = h.track_width();
// left lowers the value; on the control's steps and inside its range
h.scrub(-width, 8, false);
assert_eq!(h.value, -5.0, "clamped at the minimum");
// Shift moves a tenth as far
let mut h = Harness::new();
h.scrub(width / 5.0, 10, true);
assert!((h.value - 0.91).abs() < 0.015, "fine scrub to {}", h.value);
assert!(((h.value / 0.01).round() * 0.01 - h.value).abs() < 1e-9, "on the 0.01 steps");
}
#[test]
fn a_press_that_barely_moves_still_types() {
// a hand's jitter under the scrub distance is a click: the value can be typed
let mut h = Harness::new();
let pos = h.rect.center();
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.frame(vec![Event::PointerMoved(pos)]);
h.frame(vec![button(pos, true)]);
h.frame(vec![Event::PointerMoved(pos + vec2(1.5, 0.5))]);
assert_eq!(h.frame(vec![button(pos + vec2(1.5, 0.5), false)]).value, None);
h.frame(vec![]);
assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click");
h.frame(vec![Event::Text("2".into())]);
assert_eq!(h.key(Key::Enter).value, Some(2.0));
}
#[test]
fn a_disabled_value_does_not_scrub() {
let mut h = Harness::new();
h.enabled = false;
let width = h.track_width();
assert!(h.scrub(width / 5.0, 10, false).iter().all(|o| o.value.is_none() && !o.drag_started));
assert_eq!(h.value, 0.71);
}
@@ -0,0 +1,36 @@
//! Real egui pointer frames on a disabled slider: its name doesn't reset it (issue #1436).
use super::{SliderOut, slider, take_registry};
use crate::headless::HeadlessView;
use egui::{Event, Modifiers, PointerButton, Rect, vec2};
use lightcraft_develop::controls;
fn frame(view: &mut HeadlessView, time: f64, enabled: bool, events: Vec<Event>) -> (SliderOut, Rect) {
let spec = controls::find("light.exposure").unwrap();
let mut out = SliderOut::default();
view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, time, events), |ui| {
out = slider(ui, spec, 1.25, enabled, None);
});
let name = take_registry(&view.ctx).into_iter().find(|(id, _)| id == "sliderLabel:light.exposure").unwrap().1;
(out, name)
}
#[test]
fn a_disabled_sliders_name_ignores_a_double_click() {
for enabled in [true, false] {
let mut view = HeadlessView::new();
let mut time = 0.0;
let (_, name) = frame(&mut view, time, enabled, vec![]);
let pos = name.left_center() + vec2(8.0, 0.0);
let mut reset = false;
for pressed in [None, Some(true), Some(false), Some(true), Some(false), None] {
time += 0.02;
let events = match pressed {
None => vec![Event::PointerMoved(pos)],
Some(pressed) => vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }],
};
let (out, _) = frame(&mut view, time, enabled, events);
reset |= out.reset || out.value.is_some();
}
assert_eq!(reset, enabled, "enabled: {enabled}");
}
}
+37
View File
@@ -143,6 +143,43 @@ SFace's builtin speed metadata now uses the measured 6.9× ratio. User `catalog.
Older published tables for MobileFaceNet/R50/R100 came from a different runtime and are not measurements of this runner; architecture
names alone do not guarantee the same speed, and those additional model files were not verified in this task.
### On the GPU
On a machine with a usable GPU, the whole detector and recogniser run on it through wgpu (`crates/gpu/src/faces.rs`).
The interpreter lowers the graph to a flat program for the input's shape (`crates/faces/src/net/program.rs`): each
convolution (`wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input straight into workgroup memory,
cuts K into slices when a map is small, and a direct kernel for depthwise layers) and every other operator (activations,
batch norm, PRelu, broadcasting add/sub/mul, max pooling, nearest upscaling, transposes, global pooling and the fully
connected layer, `wgsl/faces_ops.wgsl`) is a kernel, every value stays on the device between layers in reused
buffers, and a run is one upload, one submission and one read-back. Shape-only operators make no step. A model that
needs something the program cannot express runs its convolutions one by one on the GPU instead. Several faces of one
photo go together (`Embedder::embed_batch`, up to 16 per submission).
The CPU interpreter remains the reference. After a model loads, a background check (a row in the activity stack, "Checking
the GPU for faces") runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when
every output is within 1e-3 of the CPU's (relative to the output's largest value) **and** it was faster; until then,
and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference, or **Settings ▸
Faces ▸ Run face models on the CPU only**), missing, wrong or slower, the model runs on the CPU. The first GPU error,
or a result of the wrong size or with values that are not finite, drops it for that model. Settings ▸ Faces shows
which device each model runs on (`faces.gpu`). The weights stay on a device of the scan's own (separate from the
editor's) while the model is loaded, at most 768 MB per model.
The background scan's pace also limits the GPU: paused or light, one model run at a time and the GPU busy at most a
quarter of the time (the editor renders on the same GPU); normal, two at once and three quarters; full, as fast as it
goes.
Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under very heavy load from
other jobs (load average 100–250, so the absolute numbers are noisy):
| Model | CPU | GPU, one layer at a time | GPU, whole network | Batch of 16, per face |
| --- | ---: | ---: | ---: | ---: |
| YuNet (640², one photo) | 58–60 ms | 85–89 ms | 21 ms | – |
| SFace 2021dec | 40–42 ms | 47–51 ms | 9.6 ms | 8.1 ms |
| AuraFace v1 | 295–308 ms | 165–172 ms | 70–73 ms | 71 ms |
`LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks
agreement on the real models and every operator, and prints these timings.
### Agreement and regression coverage
The public-crop embeddings match the reference runtime's outputs:
+7 -4
View File
@@ -16,9 +16,9 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| # | Gap | Evidence | User impact | Hours | Doc |
|---:|---|---|---|---:|---|
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
| 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
| 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) |
| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) |
| 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) |
| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) |
| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) |
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
@@ -36,14 +36,14 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) |
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) |
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them,
**~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)).
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
## Closed recently (kept for calibration)
- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like
Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export.
- 2026-10-11: Sony ILCA-99M2 profile.
- 2026-10-11: Nikon edge bars on bodies without CropArea (#976, #914): 36 model / size / depth groups measured.
- 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float
@@ -80,6 +82,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
| 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) |
| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain |
| 2026-10-14 | minor | Gap 4 closed: soft proofing at 1:1 renders a window (mask overlays already did); 1:1 pixels tested equal to export; only diagnostic overlays (Visualize Spots, Point Color range, HDR range) stay whole-frame |
| 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice |
| 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile |
| 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) |
+77
View File
@@ -0,0 +1,77 @@
# LightCraft in Georgian
Choose **Edit → Language → ქართული** or **Settings → General → Language**. The choice applies at once
and is saved for the next launch (`language: "ka"` in `ui.json`); the control command is
`app.language.georgian`. `LIGHTCRAFT_LANGUAGE` selects it for headless runs and accepts `ka`, `ka-GE`
and `ka_GE.UTF-8`; the saved value is always `ka`. LightCraft does not follow the system language,
for Georgian as for every other language.
The Georgian catalogs cover every key of the other catalogs plus all command and slider labels:
menus, shortcuts, library, editing, crop, masks, people, import/export, settings, built-in presets and
profiles, history, versions, date headings, status messages, the smart-album rule editor and the
unsaved-changes dialog. The embedded release notes are translated too. Technical errors from lower
layers stay in English, as in the other languages. They were written from the meaning of the English
labels; no proprietary translation resources were consulted.
File names, command ids, template placeholders and albums, presets or metadata you named yourself are
not translated. Descriptions for SAM 3 go to the model unchanged.
## Georgian script
Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the
interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts
are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with
`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`.
Inter has no Georgian letters and craft-fonts has no Georgian face yet, so Georgian text shows boxes
until a Georgian face is available to the UI.
## Terminology
| Source | Georgian |
|---|---|
| Library / Develop | ბიბლიოთეკა / დამუშავება |
| File / Edit / View | ფაილი / რედაქტირება / ხედი |
| Settings / General / Language | პარამეტრები / ზოგადი / ენა |
| Import / Export | იმპორტი / ექსპორტი |
| Photos / Albums / Folders | ფოტოები / ალბომები / საქაღალდეები |
| Smart Album | სმარტ-ალბომი |
| Crop | ჩამოჭრა |
| Masks / Masking | ნიღბები / ნიღბვა |
| Heal / Remove | აღდგენა / მოშორება |
| Presets / Profile | პრესეტები / პროფილი |
| History / Versions | ისტორია / ვერსიები |
| Light / Color / Effects / Detail | სინათლე / ფერი / ეფექტები / დეტალი |
| Exposure / Contrast | ექსპოზიცია / კონტრასტი |
| Highlights / Shadows | ღია ტონები / ჩრდილები |
| Whites / Blacks | თეთრები / შავები |
| Texture / Clarity / Dehaze | ტექსტურა / სიცხადე / ბურუსის მოცილება |
| Vibrance / Saturation | სიხალასე / გაჯერება |
| Hue / Luminance | ფერის ტონი / სიკაშკაშე |
| Tint | ტონირება |
| Tone Curve / Color Grading | ტონალური მრუდი / ფერის გრადაცია |
| Sharpening / Noise Reduction | გამკვეთრება / ხმაურის შემცირება |
| Lens Corrections / Geometry | ობიექტივის კორექციები / გეომეტრია |
| Vignette / Grain | ვინიეტი / მარცვლოვნება |
| Feather / Threshold | კიდეების შერბილება / ზღვარი |
| Rating / Flag / Keywords | შეფასება / დროშა / საკვანძო სიტყვები |
| Reset / Revert | ჩამოყრა / წინა ვერსიაზე დაბრუნება |
| Undo / Redo | დაბრუნება / გამეორება |
Please follow this table and the shared ArtCraft Georgian glossary when you add strings.
## Maintenance and checks
Fixed texts: `crates/ui-egui/locales/ka.json` (2,719 entries). Messages with values:
`crates/ui-egui/locales/ka-formats.json` (249 entries). The English keys stay unchanged; Rust checks
the placeholders at build time. Strings not yet in the plain catalog show in English.
```sh
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000
```
The tests check catalog coverage, placeholders, switching language by menu and control channel,
saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case
keeps Georgian lowercase. More notes are in
[localization.md](localization.md).
+4 -2
View File
@@ -1,12 +1,12 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian interface catalog added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How far each interface language is from `full`. How to add or maintain a language is in
[localization.md](localization.md); per-language notes are in `localization-<code>.md`
([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md),
[pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md),
[ru](localization-ru.md)).
[ru](localization-ru.md), [ka](localization-ka.md)).
## The target
@@ -66,6 +66,7 @@ that is promising but is not recorded as review, so the column says `no (communi
| Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h |
| Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h |
| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h |
| Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) |
Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai
(Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai
@@ -94,5 +95,6 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok
|---|---|---|
| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English |
| 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) |
| 2026-10-12 | minor | Georgian (`ka`) row: every catalog message |
| 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates |
| 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) |
+7 -4
View File
@@ -2,11 +2,11 @@
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`) and Czech (`cs`). This file is the reference for **adding or maintaining a language**; the per-language notes
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`) and Georgian (`ka`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md) and
[`localization-cs.md`](localization-cs.md).
[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md),
[`localization-cs.md`](localization-cs.md) and [`localization-ka.md`](localization-ka.md).
## Adding a language
@@ -33,6 +33,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json");
Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json");
It, "it", "Italiano", "Latn", include_str!("../locales/it.json");
Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json");
}
```
@@ -46,7 +47,9 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
language whose script has no face in the build shows boxes (the test says so instead of failing).
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
missing face is added there, not worked around in this repository.
missing face is added there, not worked around in this repository. Sentence-cased messages go
through `i18n::upper_first`, which leaves Georgian lowercase (upper-casing Mkhedruli would make
Mtavruli).
4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check).
## How a message is found
File diff suppressed because one or more lines are too long
+22 -21
View File
@@ -78,45 +78,45 @@ committed table is stale.
| Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported |
|---|---:|---:|---:|---:|---:|---|
| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
| Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
| Samsung | 128 | 4 | 115 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) |
| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) |
| Panasonic | 104 | 93 | 11 | 0 | 0 | — |
| Nikon | 99 | 73 | 23 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only |
| Nikon | 99 | 77 | 19 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only |
| Fujifilm | 97 | 65 | 9 | 23 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) |
| Google | 88 | 1 | 87 | 0 | 0 | — |
| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** |
| Google | 88 | 5 | 83 | 0 | 0 | — |
| Olympus | 71 | 17 | 3 | 51 | 0 | **compressed ORF** |
| Leica | 61 | 27 | 34 | 0 | 0 | — |
| Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) |
| Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — |
| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF |
| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF |
| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) |
| Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) |
| DJI | 21 | 4 | 17 | 0 | 0 | DNG |
| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview |
| **All** | **1,446** | **259 (18%)** | **919 (64%)** | **232 (16%)** | **36 (2%)** | |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) |
| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | |
## By use (estimated)
| Bucket | Weight | Camera fully works | On a verified body | Why |
|---|---:|---:|---:|---|
| Canon | 33% | 94% | 72% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
| Sony | 27% | 98% | 91% | bulk-checked |
| Nikon | 16% | 98% | 83% | decoders cover every NEF coding but HE; 73 bodies verified |
| Canon | 33% | 95% | 93% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
| Sony | 27% | 98% | 92% | bulk-checked |
| Nikon | 16% | 98% | 89% | decoders cover every NEF coding but HE; 77 bodies verified |
| Fujifilm | 10% | 94% | 92% | |
| Panasonic | 4% | 100% | 98% | bulk-checked |
| OM / Olympus | 3% | 6% | 5% | compressed ORF |
| Phones | 3% | 93% | 1% | DNG |
| Pentax / Ricoh | 1.5% | 94% | 14% | |
| Phones | 3% | 93% | 4% | DNG |
| Pentax / Ricoh | 1.5% | 99% | 80% | |
| Leica | 1% | 99% | 72% | the Q3 verified, 13 more bodies pinned on CC0 files |
| DJI | 1% | 95% | 0% | |
| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F |
| **Weighted** | 100% | **92%** | **59%** | |
| DJI | 1% | 96% | 19% | |
| Medium format and others | 0.5% | 29% | 8% | IIQ, 3FR, X3F |
| **Weighted** | 100% | **94%** | **85%** | |
About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually
checked, and Canon, the largest bucket, is the weakest of the big four.
About 19 in 20 raw photographers' cameras decode, and about 5 in 6 use a body we have actually
checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus) is the largest gap left.
## Variant matrix (depth per format)
@@ -265,7 +265,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
|---:|---|---:|---:|---:|---|
| 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) |
| 2 | CR3: remaining CRX variants (no public file uses them) | +0.3 | 10–25 | ~0.02 | files that open as a preview, from users |
| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples |
| 3 | ~~Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks)~~ done (#1045, #1081) | (+2.0, taken) | — | — | public samples |
| 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists |
| 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us |
| 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples |
@@ -310,3 +310,4 @@ Beyond decoding:
| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) |
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged |
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
+1 -1
View File
@@ -66,7 +66,7 @@ the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a
Local macOS, Linux and Windows packaging scripts automatically use the sibling `../craft-fonts`
checkout. If it is absent, they clone it at revision
`8dcdacd5153e64560d109541a47d806f26f048c0` (keep the pins in `packaging/fonts.sh` and
`3d9ab3a132ed0eff457e424226a435a901da30d3` (keep the pins in `packaging/fonts.sh` and
`packaging/windows/fonts.ps1` in sync with the release workflow). Existing checkouts are left untouched.
Set `CRAFT_FONTS_DIR` to use another
checkout; relative paths resolve from the workspace root. The script requires the Chinese and Japanese
+1 -1
View File
@@ -77,7 +77,7 @@ calibration: M0–M13 took ≈ 25 active hours against an estimate of ≈ 110–
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; contact-sheet PDF 🟡; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian/Georgian localisation 🟡; accessibility and further locales ⬜) |
## Risks that coding hours alone don't retire
+5 -4
View File
@@ -157,7 +157,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point).
| Discount | Mainstream | Essentials | Evidence |
|---|---:|---:|---|
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) |
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) |
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) |
| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) |
| **Product** | **×0.804** | **×0.830** | |
@@ -235,13 +235,13 @@ open as previews, and colour on every non-DNG raw is estimated rather than measu
**Alpha gate:** all six core workflows (import, cull, develop, save and reopen, batch looks, export)
work end to end on macOS; see the table in [roadmap.md](roadmap.md#alpha-gate). The partial rows (CR3
variants, compressed ORF, HE NEF as previews; capped 100% zoom) degrade but don't block a workflow.
variants, compressed ORF, HE NEF as previews) degrade but don't block a workflow.
**To beta:** ~14 points of readiness, **~340–600 h**: the nine `B` gaps in [gaps.md](gaps.md)
**To beta:** ~14 points of readiness, **~340–600 h**: the `B` gaps in [gaps.md](gaps.md)
(~230–430 h), plus burning down the ~92 open core-path user issues (40–80 h) and stability work
around them. Beta needs: CR3, ORF and
HE NEF decoding from sensor data; measured colour for the popular models and a fidelity suite with
scores; full-resolution zoom; Subject / Sky / People masks; a lens profile database for the
scores; Subject / Sky / People masks; a lens profile database for the
popular lenses; tests in CI on macOS, Windows and Linux.
## How hours are calibrated
@@ -303,6 +303,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged |
| 2026-10-14 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
@@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
| Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) |
| Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) |
| Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions |
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | **original-pixel detail at 100% with a mask overlay or soft proofing** (no zoom window there; Detail, Before/After, Compare and Reference have them; P0 gap LR-VIEW-ZOOM) |
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 |
| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys |
| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer |
| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item |
@@ -136,6 +136,7 @@ a real screen reader on any platform yet.
| 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status |
| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` grid thumbnail size keys noted in the shortcuts section |
| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged |
| 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed |
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |
+29
View File
@@ -146,6 +146,12 @@
- In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or
brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive
choice is never the eye-catching default.
### Straight brush strokes with ⇧-click
- With the brush, click once, then ⇧-click somewhere else: the brush paints a straight line between the two, and
further ⇧-clicks carry on from the last point, as in Lightroom. Change the brush size, feather or flow before the
⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.
`mask.brushStroke {connect: true}` does the same for agents.
### Local folders you browse are kept
- Browsing a folder under Local now records it as browsed, so the photos in folders you use aren't forgotten after
the number of days set in Settings → Performance. Only the `library.browse` command recorded it before, so a
@@ -265,6 +271,23 @@
else (issue #1504): trimming or clearing the cache can't delete files outside it, and thumbnails are never read
from or written through such a link.
### Synchronize Folder asks a network share less
- A Synchronize Folder scan no longer asks the disk about every photo of the library in the folder (is its file
there, has it a sidecar): it has just listed the folder, and reads the answers off the listing. On a network share
each of those questions is a round trip. Measured on a hard disk over SMB, for folders of 1,600 to 2,400 photos
that had not changed: 4.6 to 7.1 s of questions after the listing (about 3 ms a photo), now under 0.05 s. The
listing itself took 4 to 5 s and is as it was.
- The disk is still asked wherever a listing can't be sure what a file system would say: about a name that is
there in another case, a link, a name Windows reads another way (`aux.jpg`, a `~`, a `:`, a dot or a space at the
end), a folder the scan did not walk to its end, and, in a folder that holds any name with a character outside
ASCII (an umlaut, a curly apostrophe, a dash), about every name the folder does not list. Names it does list are
answered there too. In such a folder the scan saves less, and its checks now start after the listing where they
ran beside it.
- A photo the folder lists is there for the scan, also where its file could not be opened by its path (a folder
that may be listed but not searched, a path longer than the system takes): such photos used to be listed as
missing. And a share that drops after the scan has listed a folder no longer turns that folder's photos into
missing ones.
### Large libraries: Synchronize Folder, import and sidecars start sooner
- Opening Synchronize Folder, an import review or an import no longer makes a table of every photo in the library
before it looks at the first file. In a library of 3.5 million photos that held the window for about 6 s before
@@ -335,6 +358,12 @@
"LightCraft stopped unexpectedly" and why, instead of "could not open its window", and the next start renders
on the CPU and explains how to turn the GPU back on.
### Resetting a slider
- Resting the pointer on a slider's name or its thumb now says that a double-click resets it ("Exposure — double-click
to reset it"), in every language (issue #1436).
- A double-click on the thumb is a single step in History and Undo. Its first click no longer nudges the value to
where the pointer landed, and a single click on the thumb leaves the value alone.
### Slider drags on large raws
- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you
let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's
+1 -1
View File
@@ -10,7 +10,7 @@ prepare_fonts() {
if [ -z "${CRAFT_FONTS_DIR:-}" ] && [ ! -e "$font_dir" ]; then
# Same revision as release.yml. Never update or reset an existing checkout.
local revision=8dcdacd5153e64560d109541a47d806f26f048c0
local revision=3d9ab3a132ed0eff457e424226a435a901da30d3
echo "==> downloading craft-fonts ($revision) to $font_dir"
if ! git clone --no-checkout https://github.com/storytold/craft-fonts "$font_dir" ||
! git -C "$font_dir" checkout --detach "$revision"; then
+1 -1
View File
@@ -6,7 +6,7 @@ function Initialize-CraftFonts([string] $WorkspaceRoot) {
if (-not $env:CRAFT_FONTS_DIR -and -not (Test-Path -LiteralPath $fontDir)) {
# Same revision as release.yml and packaging/fonts.sh. Existing checkouts stay untouched.
$revision = '8dcdacd5153e64560d109541a47d806f26f048c0'
$revision = '3d9ab3a132ed0eff457e424226a435a901da30d3'
Write-Output "==> downloading craft-fonts ($revision) to $fontDir"
& git clone --no-checkout https://github.com/storytold/craft-fonts $fontDir
if ($LASTEXITCODE -ne 0) { throw 'Could not download craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' }