mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
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:
Generated
+1
@@ -3304,6 +3304,7 @@ dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
|
||||
@@ -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
@@ -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% |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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, ¬hing);
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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])")),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 を確認中"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Проверка ГП для лиц"
|
||||
}
|
||||
|
||||
@@ -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": "Перевірка ГП для облич"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(&[]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('.');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-13
File diff suppressed because one or more lines are too long
+22
-21
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user