From 76f12dcabdd361167bd82187fe9f0c15d6851498 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 03:37:35 +1100 Subject: [PATCH 01/20] Issue #989: fill_box reads its buffer with as_chunks::<3>() (clippy 1.99) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pipeline/src/spots.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..5b8cac6b3 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,9 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + let (px, _) = out.as_chunks::<3>(); + for (d, v) in src.iter_mut().zip(px) { + *d = *v; } } From c78b457c0d0783935ace0afccad16879aca1ac64 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 03:37:35 +1100 Subject: [PATCH 02/20] LR-VIEW-ZOOM: soft proofing at 1:1 renders a zoom window; 1:1 pixels tested equal to export Soft proof is per-pixel, so the After window carries the proof like the whole-frame render under it (the proof key is part of the tile's look). New tests: a 1:1 window of a 24 MP photo equals the full-size export to within one level; soft proofing at 1:1 is a canvas-sized frame plus a native window. profile_slider_drag gains a soft-proof case. Gap 4 closed, checklist row to done with measured Linux latencies. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 5 ++- crates/engine/src/media.rs | 34 +++++++++++++++ crates/ui-egui/src/panels/detail.rs | 26 +++++++----- crates/ui-egui/src/region.rs | 10 ++--- crates/ui-egui/src/tests_preview_limit.rs | 50 +++++++++++++++-------- docs/gaps.md | 7 +++- docs/parity-checklist.md | 5 ++- docs/target-app-parity.md | 11 ++--- docs/ui-parity.md | 5 ++- 9 files changed, 108 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..e7c55966d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (full-resolution zoom done) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -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) @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..156db7d41 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1931,6 +1931,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"); + } + } + #[test] fn has_source_follows_what_the_session_accepted() { let mut s = crate::Session::with_demo(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..800e27f7d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -330,10 +330,14 @@ 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 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 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 }); + // 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)); @@ -443,15 +447,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); let overlay = view_overlay(app, &d); - // 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_scale: scale, - 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))) { @@ -519,10 +524,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, diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..c18ea380e 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -73,8 +73,8 @@ pub struct ViewSizes { /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: 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, } @@ -97,7 +97,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 = if v.draft_scale.is_finite() { v.draft_scale.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 scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; @@ -281,7 +281,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] @@ -332,7 +332,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() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..93c5f86c1 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -227,6 +227,29 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // 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() { @@ -534,10 +557,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(); @@ -561,9 +590,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], @@ -692,18 +722,6 @@ mod in_the_loupe { assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::>()); } - // 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] diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..c1276b2bf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 4 closed: soft proofing gets zoom windows) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -18,7 +18,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577) | Δ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) | | 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) | @@ -59,6 +59,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-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345), JPEG XL export (#247). - 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), @@ -72,6 +74,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | 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-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..53a9156af 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -129,7 +129,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-VIEW-ZOOM 🟡 → ✅: soft proofing gets zoom windows (mask overlays already had them; row text corrected), 1:1 pixels tested equal to the full-size export, slider-drag latency at 1:1 measured on Linux | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..e2d0e4374 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 4 closed: full-resolution zoom; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -155,7 +155,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** | | @@ -233,13 +233,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 @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | 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-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44aa1c703..4379f48da 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (zoom row: soft proofing sharp at 1:1) · **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 | @@ -78,5 +78,6 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed | | 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 | From cb8b797cd44040fe889b146e1e05b329902c9690 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Tue, 13 Oct 2026 13:16:08 +0530 Subject: [PATCH 03/20] KEY-SLIDER-RESET: a slider's name and thumb say a double-click resets it (fixes #1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double-click on a slider's name or thumb already reset it, but nothing said so: only the value had a tooltip. Resting the pointer on the name or on the thumb now shows "Exposure — double-click to reset it", the wording (and translations) the curve channels already use. The rest of the track, which is clicked and dragged, gets no tooltip, and the value keeps "Click to type a value". The first click of a double-click on the thumb moved the value to where the pointer landed, a few points off its centre: one history step, then the reset another. A click on the thumb now leaves the value alone, so the reset is one undo step, as from the name. A disabled slider's name no longer resets it. No per-slider reset button: Lightroom has none, and the double-click it documents is now shown where it works. Tests: tests_slider_reset.rs (name and thumb reset Exposure and Contrast in one undo step, the hints, a click on the thumb keeps the value, typing a value still works) and widgets/tests_slider_reset.rs (a disabled slider's name). --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_slider_reset.rs | 151 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 29 +++- .../ui-egui/src/widgets/tests_slider_reset.rs | 36 +++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 6 + 6 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/tests_slider_reset.rs create mode 100644 crates/ui-egui/src/widgets/tests_slider_reset.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..4dd6effda 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,8 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_slider_reset; +#[cfg(test)] mod tests_switch_library; #[cfg(test)] mod tests_sync; diff --git a/crates/ui-egui/src/tests_slider_reset.rs b/crates/ui-egui/src/tests_slider_reset.rs new file mode 100644 index 000000000..6d0357fcb --- /dev/null +++ b/crates/ui-egui/src/tests_slider_reset.rs @@ -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::>() + ); + } + // 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"))); +} + +/// 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"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..9a9f86c36 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -176,9 +176,12 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { if shown == "+0" || shown == "-0" { "0".to_string() } else { shown } } +/// 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. 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. Click the value to type one: Return or clicking away applies it, Esc keeps the old one. 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(); @@ -214,7 +217,12 @@ 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 }; + 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) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); @@ -241,6 +249,12 @@ 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() { // Keep the owned opening release focused without restarting its draft or selection. @@ -313,6 +327,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); @@ -391,7 +408,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } } - 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(); @@ -848,6 +865,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") } diff --git a/crates/ui-egui/src/widgets/tests_slider_reset.rs b/crates/ui-egui/src/widgets/tests_slider_reset.rs new file mode 100644 index 000000000..091768ec7 --- /dev/null +++ b/crates/ui-egui/src/widgets/tests_slider_reset.rs @@ -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) -> (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}"); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..ba1babb61 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEY-SLIDER-RESET: a slider's name and thumb say a double-click resets it, one undo step (#1436); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -666,7 +666,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | `cmd:view.cropOverlayOrientation` | while cropping: O cycles the overlay, ⇧O its orientation | | KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active | -| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | +| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/tests_slider_reset.rs` | double-click a slider's name or thumb: back to its default as one undo step (a click on the thumb no longer moves it first, which made it two); resting the pointer on either says so (“Exposure — double-click to reset it”, as the curve channels), the value keeps “Click to type a value” (issue #1436); a disabled slider's name doesn't reset it. No per-slider reset button (Lightroom has none); no ⌥ panel headings turning into Reset buttons yet (`cmd:develop.resetSection` is command-only) | | KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..86e626fe0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-13 +### 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 From ab64e9a563bcfbe1ca200f17f466ad20d6bdb998 Mon Sep 17 00:00:00 2001 From: good-news-everyone Date: Tue, 13 Oct 2026 19:55:17 +0300 Subject: [PATCH 04/20] KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it As over Lightroom's values, a press on the number next to a slider's name that moves sideways now changes the value as dragging the thumb would: the pointer's travel over the track's width, a tenth as far with Shift, on the control's steps and range, as one undo step. A press that moves less than 3 points sideways is still a click that opens the value for typing (KEY-SLIDER-TYPE), and the pointer over the value is the horizontal-resize cursor. The tooltip says both ("Click to type a value, drag to change it"), translated in every catalog. Tests: scrub distance and direction, Shift, clamping, steps, one interaction per scrub, no field or draft left behind, a jittery click still types, a disabled slider doesn't scrub. The readout test that drags away from the value now drags straight down, so it still checks that a cancelled press keeps neither a value nor the keyboard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/tests_crop_rotate.rs | 2 +- crates/ui-egui/src/widgets.rs | 65 ++++++++++++-- .../src/widgets/tests_numeric_entry.rs | 86 ++++++++++++++++++- docs/parity-checklist.md | 12 +-- 13 files changed, 159 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..8d2361433 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1911,7 +1911,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "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", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..e7f30f677 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1791,7 +1791,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", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..a0acb8941 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1745,7 +1745,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", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..0aae78d39 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1755,7 +1755,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": "著作権あり", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..b29b0dba5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1755,7 +1755,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "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", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..4903f1620 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1428,7 +1428,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": "Видео", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..2c162ce96 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1939,7 +1939,7 @@ "Recognition models": "Моделі розпізнавання", "Decrease Rating": "Зменшити оцінку", "Increase Rating": "Збільшити оцінку", - "Click to type a value": "Клацніть, щоб ввести значення", + "Click to type a value, drag to change it": "Клацніть, щоб ввести значення, або перетягніть, щоб змінити", "Angle": "Кут", "Video": "Відео", "Copyrighted": "Захищено авторським правом", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..84d36bdbb 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1755,7 +1755,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": "受版权保护", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..8ddd637a5 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1755,7 +1755,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": "受著作權保護", diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index 8e27411eb..f19171ffc 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -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::>() ); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..ffcaab5c5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -176,9 +176,14 @@ 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; + /// 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. Click the value to -/// type one: Return or clicking away applies it, Esc keeps the old one. +/// 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(); @@ -216,15 +221,39 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ }); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); 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() }); + let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); 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 = 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::(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) { @@ -242,7 +271,28 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ 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())); 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::(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. @@ -387,8 +437,9 @@ 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; diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 5e54ff064..278ff3ba8 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -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 { + 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); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..e18837b24 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEY-SLIDER-SCRUB: scrub a slider's value by dragging it; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -51,11 +51,11 @@ cited here still exists. Formerly docs/parity.md. | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | +| Z. Keyboard shortcuts (desktop) | 77 | 3 | 3 | 1 | 49/52 (94%) | 24/25 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 410 | 52 | 72 | 35 | 193/201 (96%) | 147/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 159 · P2 50.0% of 173. ## Top gaps and shortcut notes @@ -669,6 +669,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | | KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | +| KEY-SLIDER-SCRUB | Scrub a slider value — drag the number | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `SCRUB_START`), `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`dragging_the_value_sideways_scrubs_it_like_the_thumb`, `scrubbing_back_and_with_shift`, `a_press_that_barely_moves_still_types`) | drag the value next to any slider's name sideways: it moves as dragging the thumb would (the pointer's travel over the track's width, ⇧ a tenth as far), on the control's steps and range, as one undo step; a press that moves less than 3 points sideways stays a click that types a value (KEY-SLIDER-TYPE); the pointer over the value is the horizontal-resize cursor, as Lightroom's scrubby value | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | @@ -789,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 0242a75b5a8b33f648cfeebf188ecf1083081058 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Wed, 14 Oct 2026 12:22:50 +1100 Subject: [PATCH 05/20] Faces: run the networks' convolutions on the GPU (wgpu) when it agrees with the CPU and is faster An accelerator hook in the face interpreter takes Conv nodes; lightcraft-gpu implements it with a WGSL implicit-GEMM convolution (any stride, padding, grouping; split-K for small maps; a direct kernel for depthwise layers), each model's weights resident on a device of its own. After loading, detection and recognition check the GPU in the background (agreement within 1e-3 and faster than the CPU) and otherwise stay on the CPU, which remains the reference; the first GPU error drops it. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/engine/src/cmd/face_detect.rs | 28 ++ crates/engine/src/cmd/face_recognize.rs | 2 + crates/faces/src/lib.rs | 3 +- crates/faces/src/net.rs | 215 ++++++++- crates/faces/src/net/kernels.rs | 27 ++ crates/faces/src/runtime.rs | 25 + crates/faces/src/yunet.rs | 22 + crates/gpu/Cargo.toml | 1 + crates/gpu/src/faces.rs | 593 ++++++++++++++++++++++++ crates/gpu/src/lib.rs | 6 +- crates/gpu/src/wgsl/faces_conv.wgsl | 171 +++++++ docs/faces.md | 28 ++ 13 files changed, 1117 insertions(+), 5 deletions(-) create mode 100644 crates/gpu/src/faces.rs create mode 100644 crates/gpu/src/wgsl/faces_conv.wgsl diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..db28a530d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3299,6 +3299,7 @@ dependencies = [ "lightcraft-color", "lightcraft-denoise", "lightcraft-develop", + "lightcraft-faces", "lightcraft-geom", "lightcraft-pipeline", "lightcraft-raster", diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index ffdedbef1..8e706c367 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -56,10 +56,38 @@ pub(crate) fn detector(s: &Session) -> std::result::Result, String } let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?; let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?); + #[cfg(not(target_arch = "wasm32"))] + { + let net = d.clone(); + offer_gpu("detection", move |gpu| net.accelerate(gpu, true)); + } *loaded = Some((path, d.clone())); Ok(d) } +/// 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. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn offer_gpu( + what: &'static str, + attach: impl FnOnce(Arc) -> std::result::Result<(), String> + Send + 'static, +) { + let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || { + let gpu = match lightcraft_gpu::faces::accelerator() { + Ok(gpu) => gpu, + Err(why) => return log::info!("faces: {what} runs on the CPU ({why})"), + }; + let device = lightcraft_faces::net::Accelerator::device(&*gpu); + match attach(gpu) { + Ok(()) => log::info!("faces: {what} runs on {device}"), + Err(why) => log::info!("faces: {what} stays on the CPU: {why}"), + } + }); + if let Err(e) = spawned { + log::warn!("faces: could not check the GPU for {what}: {e}"); + } +} + fn face_json(f: &Face) -> Value { json!({ "rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1}, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..094735c6d 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -234,6 +234,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("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")), diff --git a/crates/faces/src/lib.rs b/crates/faces/src/lib.rs index b4b7066d5..030cda088 100644 --- a/crates/faces/src/lib.rs +++ b/crates/faces/src/lib.rs @@ -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)] diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 6222314f2..3983404c5 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::rc::Rc; +use std::sync::{Arc, PoisonError, RwLock}; mod kernels; mod shape; @@ -64,9 +65,58 @@ 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>, String>; +} + /// A loaded network, ready to run. pub struct Net { graph: Graph, + accelerator: RwLock>>, } /// Validate semantics rather than silently ignoring unknown or mistyped attributes. @@ -80,7 +130,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> { + 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, + 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> { + let Some(accelerator) = self.accelerator() else { return Ok(None) }; + let job = kernels::conv_job(layer, node, x, &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). @@ -127,7 +245,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 +292,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 +677,74 @@ 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>, 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); + } + } + #[test] fn unsupported_and_inconsistent_models_are_errors() { let x = input(1, 3, 3, |i| i as f32); diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 73cc72b93..6d7c1dbb9 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -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, x: &'a Tensor, g: &'a Graph) -> Result> { + let (cin, h, w) = chw(x)?; + 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: &x.data }) +} pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result { let (cin, h, w) = chw(x)?; let wt = shape::weight(n, g, 1)?; diff --git a/crates/faces/src/runtime.rs b/crates/faces/src/runtime.rs index bbb1457eb..7a8ab948a 100644 --- a/crates/faces/src/runtime.rs +++ b/crates/faces/src/runtime.rs @@ -99,6 +99,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, 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 = 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) diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..d1c62f75c 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -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 { + (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, 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 diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..060abb112 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -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 } diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs new file mode 100644 index 000000000..ae982d30b --- /dev/null +++ b/crates/gpu/src/faces.rs @@ -0,0 +1,593 @@ +//! Face networks on the GPU: an [`lightcraft_faces::net::Accelerator`] that runs the interpreter's convolutions (most of +//! YuNet's, SFace's and AuraFace's time) with a WGSL kernel (`wgsl/faces_conv.wgsl`). Everything else in the graph +//! stays on the CPU interpreter, which is the reference: a net attaches this only after it agreed with the CPU on a +//! test input (`Net::attach_checked`), and drops it on the first error. +//! +//! The device is a separate one (like denoise's), so a face scan and the interactive renders never share a queue, and +//! it respects the same switches as GPU rendering (`LIGHTCRAFT_GPU`, `LIGHTCRAFT_GPU_BACKEND`, the GPU preference) +//! and the crash sentinel around device creation. A model's weights are uploaded once, on first use, and stay on the +//! device while the accelerator (one per loaded model) lives. Every failure is an `Err`, never a panic. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use bytemuck::{Pod, Zeroable}; +use lightcraft_faces::net::{Accelerator, ConvJob}; +use wgpu::util::DeviceExt; + +/// Longest wait for one convolution before the device is given up on. +const TIMEOUT: Duration = Duration::from_secs(30); +/// Below this many multiply-adds a convolution stays on the CPU: the upload, dispatch and read-back cost more. +const MIN_MACS: usize = 1 << 20; +/// Workgroups a convolution should make at least, cutting its K into slices when its tiles are too few. +const TARGET_WORKGROUPS: usize = 256; +/// Weights one model may keep on the device (AuraFace's are about 250 MB). +const MAX_RESIDENT: u64 = 768 << 20; + +struct Dev { + device: wgpu::Device, + queue: wgpu::Queue, + info: wgpu::AdapterInfo, + limits: wgpu::Limits, + layout: wgpu::BindGroupLayout, + tiled: wgpu::ComputePipeline, + direct: wgpu::ComputePipeline, + reduce: wgpu::ComputePipeline, +} + +static DEV: OnceLock> = OnceLock::new(); +static BROKEN: AtomicBool = AtomicBool::new(false); +static BROKEN_WHY: Mutex> = Mutex::new(None); + +fn mark_broken(why: &str) { + let mut w = BROKEN_WHY.lock().unwrap_or_else(PoisonError::into_inner); + if w.is_none() { + *w = Some(why.to_string()); + } + BROKEN.store(true, Ordering::Relaxed); +} + +/// Why the GPU stopped being used for faces (a device error), if it did. +pub fn broken_reason() -> Option { + BROKEN.load(Ordering::Relaxed).then(|| BROKEN_WHY.lock().unwrap_or_else(PoisonError::into_inner).clone().unwrap_or_else(|| "device error".into())) +} + +fn dev() -> Result<&'static Dev, String> { + if let Some(why) = broken_reason() { + return Err(format!("stopped after a GPU failure: {why}")); + } + if let Some(why) = crate::switched_off() { + return Err(why); + } + let _work = match DEV.get() { + Some(_) => None, + None => Some(crate::exit::enter().ok_or("the app is closing")?), + }; + DEV.get_or_init(create_device).as_ref().map_err(|e| e.clone()) +} + +fn create_device() -> Result { + let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + crate::backend::with_init_marker(backends, || { + std::panic::catch_unwind(|| make_device(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) + }) +} + +fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + } +} + +fn make_device(backends: wgpu::Backends) -> Result { + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + desc.backend_options = crate::backend::backend_options(); + desc.flags = crate::backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); + let adapter = match &wanted { + Some(w) => pollster::block_on(instance.enumerate_adapters(backends)) + .into_iter() + .find(|a| { + let i = a.get_info(); + i.device_type != wgpu::DeviceType::Cpu && i.name.to_lowercase().contains(w.as_str()) + }) + .ok_or_else(|| format!("no GPU adapter named like `{w}`"))?, + None => pollster::block_on( + instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), + ) + .map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?, + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu { + return Err(format!("software adapter ({}) skipped: the CPU is faster", info.name)); + } + let limits = adapter.limits(); + if limits.max_storage_buffers_per_shader_stage < 4 + || limits.max_compute_invocations_per_workgroup < 256 + || limits.max_compute_workgroup_size_x < 256 + || limits.max_compute_workgroup_storage_size < 8 * 1024 + { + return Err(format!("{} is too limited for the face kernels", info.name)); + } + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("lightcraft-faces"), + required_limits: limits.clone(), + ..Default::default() + })) + .map_err(|e| format!("{}: device creation failed ({e})", info.name))?; + device.on_uncaptured_error(Arc::new(|e| { + log::error!("gpu (faces): {e}"); + mark_broken(&format!("device error: {e}")); + })); + device.set_device_lost_callback(|reason, msg| { + log::error!("gpu (faces): device lost ({reason:?}): {msg}"); + mark_broken(&format!("device lost ({reason:?}): {msg}")); + }); + use wgpu::BufferBindingType::{Storage, Uniform}; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("faces conv"), + entries: &[ + entry(0, Uniform), + entry(1, Storage { read_only: true }), + entry(2, Storage { read_only: true }), + entry(3, Storage { read_only: true }), + entry(4, Storage { read_only: false }), + ], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("faces conv"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_conv.wgsl").into()), + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipe = |entry: &str| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&pl), + module: &module, + entry_point: Some(entry), + // both kernels write all of their workgroup memory before reading it + compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() }, + cache: None, + }) + }; + let (tiled, direct, reduce) = (pipe("tiled"), pipe("direct"), pipe("reduce")); + log::info!("gpu (faces): {} ({:?})", info.name, info.backend); + Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce }) +} + +/// The adapter face scans would use ("AMD Radeon RX 7900 XTX (Vulkan)"), or why they cannot use one. Creates the +/// device on first use. +pub fn adapter() -> Result { + dev().map(|d| format!("{} ({:?})", d.info.name, d.info.backend)) +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Params { + m: u32, + n: u32, + k: u32, + cg: u32, + h: u32, + w: u32, + ow: u32, + kh: u32, + kw: u32, + sh: u32, + sw: u32, + pt: u32, + pl: u32, + bias: u32, + total: u32, + row: u32, + splits: u32, + kchunk: u32, + _a: u32, + _b: u32, +} + +/// A layer's weights and bias on the device. +struct Resident { + weights: wgpu::Buffer, + bias: wgpu::Buffer, +} + +/// The GPU accelerator for one loaded face model: its weights stay on the device while it lives. +pub struct FaceGpu { + dev: &'static Dev, + layers: Mutex>>, + resident: AtomicU64, +} + +/// A GPU accelerator for one face model, or why there is none (GPU off, missing or broken). +pub fn accelerator() -> Result, String> { + Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) +} + +fn u32_of(v: usize) -> Result { + u32::try_from(v).ok().filter(|v| *v < (1 << 31)).ok_or_else(|| "a layer too large for the GPU kernel".to_string()) +} + +impl FaceGpu { + /// Bytes of weights this model keeps on the device. + pub fn resident_bytes(&self) -> u64 { + self.resident.load(Ordering::Relaxed) + } + + fn resident(&self, job: &ConvJob) -> Result>, String> { + let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(r) = layers.get(&job.layer) { + return Ok(Some(r.clone())); + } + let bytes = (job.weights.len() as u64 + job.cout as u64) * 4; + if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { + return Ok(None); + } + let d = self.dev; + let usage = wgpu::BufferUsages::STORAGE; + let weights = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces weights"), + contents: bytemuck::cast_slice(job.weights), + usage, + }); + let zero = [0f32; 1]; + let bias = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces bias"), + contents: bytemuck::cast_slice(job.bias.unwrap_or(&zero)), + usage, + }); + let r = Arc::new(Resident { weights, bias }); + self.resident.fetch_add(bytes, Ordering::Relaxed); + layers.insert(job.layer, r.clone()); + Ok(Some(r)) + } + + fn run(&self, job: &ConvJob) -> Result>, String> { + let d = self.dev; + let groups = job.groups.max(1); + let (m, cg) = (job.cout / groups, job.cin / groups); + let n = job.oh.checked_mul(job.ow).ok_or("a layer too large")?; + let k = cg.checked_mul(job.kh).and_then(|v| v.checked_mul(job.kw)).ok_or("a layer too large")?; + let total = job.cout.checked_mul(n).ok_or("a layer too large")?; + let direct = m < 8; + // Too few 64 x 64 tiles to keep the GPU busy (a 7 x 7 map of 512 channels makes 8): cut K into slices that + // run side by side, and sum them in a second pass. + let tiles = n.div_ceil(64) * m.div_ceil(64) * groups; + let splits = if direct { 1 } else { (TARGET_WORKGROUPS / tiles.max(1)).clamp(1, (k / 128).max(1)) }; + let kchunk = k.div_ceil(splits).div_ceil(16) * 16; + let splits = k.div_ceil(kchunk.max(1)).max(1); + let partial = total.checked_mul(splits).ok_or("a layer too large")?; + let largest = [job.input.len(), job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; + if largest > d.limits.max_storage_buffer_binding_size.min(d.limits.max_buffer_size) { + return Ok(None); + } + let flat = |count: usize| { + let blocks = count.div_ceil(256); + let row = blocks.clamp(1, 65_535); + (row, blocks.div_ceil(row), 1, row) + }; + let (wx, wy, wz, row) = if direct { flat(total) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits, flat(total).3) }; + let (rx, ry, _, _) = flat(total); + let limit = d.limits.max_compute_workgroups_per_dimension as usize; + if [wx, wy, wz, rx, ry].into_iter().any(|v| v > limit) { + return Ok(None); + } + let Some(res) = self.resident(job)? else { return Ok(None) }; + let params = Params { + m: u32_of(m)?, + n: u32_of(n)?, + k: u32_of(k)?, + cg: u32_of(cg)?, + h: u32_of(job.h)?, + w: u32_of(job.w)?, + ow: u32_of(job.ow)?, + kh: u32_of(job.kh)?, + kw: u32_of(job.kw)?, + sh: u32_of(job.sh)?, + sw: u32_of(job.sw)?, + pt: u32_of(job.pt)?, + pl: u32_of(job.pl)?, + bias: u32::from(job.bias.is_some()), + total: u32_of(total)?, + row: u32_of(row)?, + splits: u32_of(splits)?, + kchunk: u32_of(kchunk)?, + _a: 0, + _b: 0, + }; + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let scopes = [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)]; + let uniform = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(job.input), + usage: wgpu::BufferUsages::STORAGE, + }); + let bytes = total as u64 * 4; + let output = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces output"), + size: bytes, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let partials = (splits > 1).then(|| { + d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces partial sums"), + size: partial as u64 * 4, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }) + }); + let staging = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces read-back"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: input.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: partials.as_ref().unwrap_or(&output).as_entire_binding() }, + ], + }); + let summing = partials.as_ref().map(|partials| { + d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: partials.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: output.as_entire_binding() }, + ], + }) + }); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces conv"), timestamp_writes: None }); + pass.set_pipeline(if direct { &d.direct } else { &d.tiled }); + pass.set_bind_group(0, &bind, &[]); + pass.dispatch_workgroups(u32_of(wx)?, u32_of(wy)?, u32_of(wz)?); + if let Some(summing) = &summing { + pass.set_pipeline(&d.reduce); + pass.set_bind_group(0, summing, &[]); + pass.dispatch_workgroups(u32_of(rx)?, u32_of(ry)?, 1); + } + } + enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + let index = d.queue.submit([enc.finish()]); + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let waited = d.device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: Some(TIMEOUT) }); + let [oom, validation] = scopes; + let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + if let Some(e) = oom { + return Err(format!("the GPU ran out of memory: {e}")); + } + if let Some(e) = validation { + mark_broken(&format!("device error: {e}")); + return Err(format!("the GPU reported an error: {e}")); + } + if let Err(e) = waited { + mark_broken(&format!("the GPU did not finish a convolution: {e}")); + return Err(format!("the GPU did not finish: {e}")); + } + match rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("reading the result back failed: {e}")), + Err(e) => return Err(format!("reading the result back failed: {e}")), + } + let out = { + let data = slice.get_mapped_range().map_err(|e| format!("reading the result back failed: {e:?}"))?; + data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::>() + }; + staging.unmap(); + Ok(Some(out)) + } +} + +impl Accelerator for FaceGpu { + fn device(&self) -> String { + format!("{} ({:?})", self.dev.info.name, self.dev.info.backend) + } + + fn conv(&self, job: &ConvJob) -> Result>, String> { + if let Some(why) = broken_reason() { + return Err(format!("the GPU is not used any more: {why}")); + } + if crate::switched_off().is_some() || job.macs() < MIN_MACS { + return Ok(None); + } + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run(job))).unwrap_or_else(|_| { + mark_broken("a face convolution panicked"); + Err("the GPU runner gave up".into()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gpu() -> Option> { + match accelerator() { + Ok(a) => Some(a), + Err(e) => { + eprintln!("no GPU for the face kernels, skipping: {e}"); + None + } + } + } + + /// The plain definition of a convolution, to check the kernels against. + fn reference(j: &ConvJob) -> Vec { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; j.cout * j.oh * j.ow]; + for co in 0..j.cout { + let g = co / m; + 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] + * j.input[((g * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + + #[test] + fn the_gpu_convolution_gives_what_the_definition_gives() { + let Some(gpu) = gpu() else { return }; + // (cin, cout, groups, k, stride, pad, h, w): dense, strided, depthwise, grouped, pointwise, odd sizes + for (layer, (cin, cout, groups, k, s, p, h, w)) in [ + (3, 16, 1, 3, 2, 1, 41, 37), + (64, 64, 1, 3, 1, 1, 28, 28), + (32, 32, 32, 3, 1, 1, 30, 31), + (16, 32, 4, 3, 2, 1, 33, 20), + (48, 72, 1, 1, 1, 0, 50, 45), + (8, 130, 1, 5, 1, 2, 23, 19), + ] + .into_iter() + .enumerate() + { + let cg = cin / groups; + let weights: Vec = (0..cout * cg * k * k).map(|i| ((i * 37 % 23) as f32 - 11.0) * 0.03).collect(); + let bias: Vec = (0..cout).map(|i| i as f32 * 0.01 - 0.3).collect(); + let input: Vec = (0..cin * h * w).map(|i| ((i * 13 % 29) as f32 - 14.0) * 0.07).collect(); + let (oh, ow) = ((h + 2 * p - k) / s + 1, (w + 2 * p - k) / s + 1); + let job = ConvJob { + layer, + cin, + h, + w, + cout, + groups, + kh: k, + kw: k, + sh: s, + sw: s, + pt: p, + pl: p, + oh, + ow, + weights: &weights, + bias: Some(&bias), + input: &input, + }; + let got = gpu.run(&job).unwrap().unwrap(); + let want = reference(&job); + let worst = got.iter().zip(&want).fold(0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(worst < 1e-3, "case {layer}: off by {worst}"); + } + assert!(gpu.resident_bytes() > 0, "the weights stay on the device"); + } + + /// The real models on the GPU agree with the CPU interpreter: `LC_FACE_MODELS=` holding + /// `face_detection_yunet_2023mar.onnx`, `face_recognition_sface_2021dec.onnx` and (optionally) `glintr100.onnx`. + #[test] + fn real_face_models_on_the_gpu_agree_with_the_cpu() { + let Some(dir) = std::env::var_os("LC_FACE_MODELS").map(std::path::PathBuf::from) else { + eprintln!("LC_FACE_MODELS is not set: skipping"); + return; + }; + if gpu().is_none() { + return; + } + let det = lightcraft_faces::yunet::Detector::new(&std::fs::read(dir.join("face_detection_yunet_2023mar.onnx")).unwrap()).unwrap(); + let (w, h) = (900, 700); + let rgb: Vec = (0..w * h * 3).map(|i| ((i / 3 % w) as f32 * 0.25 + (i / 3 / w) as f32 * 0.3 + (i * 7919 % 41) as f32) as u8).collect(); + let opts = lightcraft_faces::yunet::Options { score: 0.0, ..Default::default() }; + let cpu = det.detect(&rgb, w, h, &opts).unwrap(); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = det.detect(&rgb, w, h, &opts).unwrap(); + assert!(det.net().accelerator().is_some()); + assert_eq!(cpu.len(), fast.len()); + for (a, b) in cpu.iter().zip(&fast) { + assert!((a.score - b.score).abs() < 1e-3, "{} vs {}", a.score, b.score); + } + for (file, sha) in [ + ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), + ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), + ] { + let path = dir.join(file); + if !path.exists() { + continue; + } + let e = lightcraft_faces::runtime::Embedder::load(&path, &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); + let (ew, eh) = e.input_size(); + let face: Vec = (0..ew * eh * 3).map(|i| ((i * 13) % 251) as u8).collect(); + let cpu = e.embed(&face).unwrap(); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = e.embed(&face).unwrap(); + let cosine: f32 = cpu.iter().zip(&fast).map(|(a, b)| a * b).sum(); + assert!(cosine > 0.9999, "{file}: cosine {cosine}"); + } + } + + /// Timings, CPU against GPU: `LC_FACE_MODELS= cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. + #[test] + #[ignore = "a benchmark: needs LC_FACE_MODELS and a GPU"] + fn timings() { + let Some(dir) = std::env::var_os("LC_FACE_MODELS").map(std::path::PathBuf::from) else { return }; + let Some(_) = gpu() else { return }; + let time = |runs: usize, f: &mut dyn FnMut()| { + f(); + let t = std::time::Instant::now(); + for _ in 0..runs { + f(); + } + t.elapsed().as_secs_f64() * 1000.0 / runs as f64 + }; + let det = lightcraft_faces::yunet::Detector::new(&std::fs::read(dir.join("face_detection_yunet_2023mar.onnx")).unwrap()).unwrap(); + let (w, h) = (1024, 768); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let opts = lightcraft_faces::yunet::Options::default(); + let cpu = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + for (file, sha) in [ + ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), + ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), + ] { + let e = lightcraft_faces::runtime::Embedder::load(&dir.join(file), &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); + let (ew, eh) = e.input_size(); + let face: Vec = (0..ew * eh * 3).map(|i| (i * 13 % 251) as u8).collect(); + let cpu = time(3, &mut || drop(e.embed(&face).unwrap())); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = time(3, &mut || drop(e.embed(&face).unwrap())); + eprintln!("{file}: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fb9785ba6..9c1de0f99 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -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 { if env_disabled() { return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); diff --git a/crates/gpu/src/wgsl/faces_conv.wgsl b/crates/gpu/src/wgsl/faces_conv.wgsl new file mode 100644 index 000000000..8384394e9 --- /dev/null +++ b/crates/gpu/src/wgsl/faces_conv.wgsl @@ -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 p: P; +@group(0) @binding(1) var src: array; +@group(0) @binding(2) var wgt: array; +@group(0) @binding(3) var bias: array; +@group(0) @binding(4) var dst: array; + +const BM: u32 = 64u; +const BN: u32 = 64u; +const BK: u32 = 16u; + +var As: array; // [BK][BM] +var Bs: array; // [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, @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, 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; + var b: array; + 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, @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, @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; +} diff --git a/docs/faces.md b/docs/faces.md index 5c8e54317..f4f4a4757 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -143,6 +143,34 @@ 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 convolutions of the detector and the recogniser (most of their time) run on it +through wgpu (`crates/gpu/src/faces.rs`, `wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input +straight into workgroup memory, cuts K into slices when a layer's map is small, and a direct kernel for depthwise +layers). Everything else in the graph stays on the CPU interpreter, which remains the reference. After a model loads, +a background check 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), missing, +wrong or slower, the model runs on the CPU. The first GPU error drops it for that model. 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 log says which +device each model runs on. + +Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under heavy load from other +jobs (so the absolute numbers are noisy; the ratios held across three runs): + +| Model | CPU | GPU | +| --- | ---: | ---: | +| YuNet (640², one photo) | 33–34 ms | 35–39 ms | +| SFace 2021dec | 25–28 ms | 22–27 ms | +| AuraFace v1 | 204–208 ms | 81–103 ms | + +Each layer still goes to the GPU and back: for YuNet and SFace that round trip costs about what the layer saves, so on +most machines they stay on the CPU by the speed check, while AuraFace runs 2–2.5× faster. Keeping activations on the GPU +between layers (the remaining operators as kernels) and batching faces per dispatch are the next steps. +`LC_FACE_MODELS= cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks +agreement on the real models and prints these timings. + ### Agreement and regression coverage The public-crop embeddings match the reference runtime's outputs: From f625c74c7a87513bcc0cf9c981eef940b79220d6 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Wed, 14 Oct 2026 19:43:44 +1100 Subject: [PATCH 06/20] Faces: whole networks on the GPU, batched faces, CPU-only switch, paced GPU use Activations stay on the device between layers (net/program.rs lowers the graph; every operator is a WGSL kernel, one submission and read-back per run); faces of a photo go in batches of up to 16; Settings > Faces shows each model's device and a CPU-only switch (faces.gpu); the scan's pace limits GPU use; the GPU check is an activity row. YuNet 58 -> 21 ms, SFace 40 -> 9.6 ms, AuraFace 295 -> 71 ms (heavily loaded machine). Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/face_detect.rs | 140 ++++- crates/engine/src/cmd/face_models.rs | 12 + crates/engine/src/cmd/face_recognize.rs | 5 +- crates/engine/src/faces_worker.rs | 28 +- crates/engine/src/tests_face_models.rs | 14 + crates/faces/src/net.rs | 241 ++++++++- crates/faces/src/net/kernels.rs | 8 +- crates/faces/src/net/program.rs | 315 +++++++++++ crates/faces/src/net/shape.rs | 10 +- crates/faces/src/runtime.rs | 76 ++- crates/faces/src/synthetic.rs | 60 +++ crates/gpu/src/faces.rs | 671 +++++++++++++++++++----- crates/gpu/src/wgsl/faces_ops.wgsl | 188 +++++++ crates/ui-egui/locales/de.json | 10 +- crates/ui-egui/locales/es.json | 10 +- crates/ui-egui/locales/fr.json | 10 +- crates/ui-egui/locales/ja.json | 10 +- crates/ui-egui/locales/pt-br.json | 10 +- crates/ui-egui/locales/ru.json | 10 +- crates/ui-egui/locales/uk.json | 10 +- crates/ui-egui/locales/zh-hans.json | 10 +- crates/ui-egui/locales/zh-hant.json | 10 +- crates/ui-egui/src/panels/faces.rs | 37 ++ docs/faces.md | 49 +- 24 files changed, 1753 insertions(+), 191 deletions(-) create mode 100644 crates/faces/src/net/program.rs create mode 100644 crates/gpu/src/wgsl/faces_ops.wgsl diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 8e706c367..718420ffc 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -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::{Result, Session}; const C: &str = "faces.detect"; @@ -34,10 +34,12 @@ const EXISTING_IOU: f64 = 0.3; /// What to tell someone who has not downloaded the detector yet. const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces"; +/// The YuNet loaded last, and from where. +static LOADED: Mutex)>> = 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, String> { - static LOADED: Mutex)>> = Mutex::new(None); let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID); let path = home.join("model.onnx"); // installing writes the manifest last, so a folder without one is not (yet) a model @@ -59,35 +61,130 @@ pub(crate) fn detector(s: &Session) -> std::result::Result, String #[cfg(not(target_arch = "wasm32"))] { let net = d.clone(); - offer_gpu("detection", move |gpu| net.accelerate(gpu, true)); + 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, + /// Why it runs on the CPU. + pub why: Option, +} + +/// Each model's [`GpuState`], by what it does ("detection", "recognition"). +static GPU_STATE: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn gpu_state(what: &str) -> Option { + 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. +/// 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) -> 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) => return log::info!("faces: {what} runs on the CPU ({why})"), + 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}"), - Err(why) => log::info!("faces: {what} stays on the CPU: {why}"), + 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 { + 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> = 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}, @@ -175,15 +272,26 @@ fn detect(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - 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)] diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 6033c4dcc..1ea39d04c 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -27,6 +27,9 @@ pub(super) struct FaceSettings { pub(super) enabled: bool, /// The installed recogniser in use. pub(super) embedder: Option, + /// 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 { "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, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index 094735c6d..509a5bc70 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -235,7 +235,7 @@ mod imp { } 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("recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string())); + 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")), @@ -584,6 +584,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 }; diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 81ff6adae..e44ad77c9 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option) -> 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 { /// `@` the embeddings are for. @@ -197,7 +208,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>, 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>, 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)); @@ -214,20 +226,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 @@ -235,6 +245,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>, 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)); diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index f1c27c849..00d6dc0cc 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -613,3 +613,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); +} diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 3983404c5..430335840 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -12,6 +12,7 @@ use std::rc::Rc; use std::sync::{Arc, PoisonError, RwLock}; mod kernels; +pub mod program; mod shape; use crate::graph::{Data, Graph, Node}; @@ -111,6 +112,12 @@ pub trait Accelerator: Send + Sync { 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>, 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>>>, String> { + Ok(None) + } } /// A loaded network, ready to run. @@ -190,7 +197,7 @@ impl Net { /// 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> { let Some(accelerator) = self.accelerator() else { return Ok(None) }; - let job = kernels::conv_job(layer, node, x, &self.graph)?; + 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), @@ -214,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> { - 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) -> Result>> { + 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>>> { + 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>> = (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::>>() + }) + .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> { self.validate_input(&input.shape)?; let mut scratch = kernels::Scratch::default(); let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some(); @@ -745,6 +812,176 @@ mod tests { } } + /// 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> { + use program::{Arg, Binary, Op, Unary}; + let mut values: Vec> = vec![Vec::new(); p.lens.len()]; + values[program::INPUT] = input.to_vec(); + for step in &p.steps { + let arg = |i: usize| -> Vec { + 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 = 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::() / *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::() + 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>, String> { + Ok(None) + } + fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result>>>, String> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut out: Vec>> = 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 = (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); diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 6d7c1dbb9..71ae95874 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -60,8 +60,8 @@ 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, x: &'a Tensor, g: &'a Graph) -> Result> { - let (cin, h, w) = chw(x)?; +pub(super) fn conv_job<'a>(layer: usize, n: &Node, shape: &[usize], input: &'a [f32], g: &'a Graph) -> Result> { + 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 { @@ -84,7 +84,7 @@ pub(super) fn conv_job<'a>(layer: usize, n: &Node, x: &'a Tensor, g: &'a Graph) 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: &x.data }) + 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 { let (cin, h, w) = chw(x)?; @@ -208,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() { diff --git a/crates/faces/src/net/program.rs b/crates/faces/src/net/program.rs new file mode 100644 index 000000000..e00e51da1 --- /dev/null +++ b/crates/faces/src/net/program.rs @@ -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>, + pub out: usize, + /// Values nothing reads after this step (never an output): their memory can be reused. + pub free: Vec, +} + +/// 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, + pub steps: Vec>, + /// Each graph output: its name, the value that holds it and its shape. + pub outputs: Vec<(String, usize, Vec)>, +} + +/// 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>> { + 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)> = HashMap::new(); + names.insert(input_name.as_str(), (INPUT, input.to_vec())); + let mut steps: Vec = 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)> { 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 = (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::>>()?; + // the last step that reads each value; a value nothing reads is free right after it is made + let mut last: Vec> = 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])")), + } +} diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index d76271bc7..5b9fc68b1 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -261,7 +261,12 @@ fn image(s: &[usize]) -> Result<(usize, usize, usize)> { } } pub(super) fn infer(g: &Graph, input: &[usize]) -> Result>> { + 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>)> { elements(input)?; + let mut per_node = Vec::with_capacity(g.nodes.len()); let mut shapes: HashMap<&str, Vec> = 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)?; @@ -484,6 +489,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result>> { .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 { @@ -492,5 +498,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result>> { } } } - 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::>()?; + Ok((per_node, outputs)) } diff --git a/crates/faces/src/runtime.rs b/crates/faces/src/runtime.rs index 7a8ab948a..92f9e8134 100644 --- a/crates/faces/src/runtime.rs +++ b/crates/faces/src/runtime.rs @@ -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 { @@ -135,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, 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, RuntimeError>> { + if self.plan.accelerator().is_none() || faces.len() < 2 { + return faces.iter().map(|f| self.embed(f)).collect(); + } + let mut results: Vec, 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, Vec) = 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 { if rgb.len() != self.width * self.height * 3 { return Err(RuntimeError::Input("the face picture is not the size the model wants".into())); } @@ -149,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) -> Result, 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))); } diff --git a/crates/faces/src/synthetic.rs b/crates/faces/src/synthetic.rs index 61010a243..6bb792a1f 100644 --- a/crates/faces/src/synthetic.rs +++ b/crates/faces/src/synthetic.rs @@ -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 { (0..n).map(|i| (((i * k) % 23) as f32 - 11.0) * 0.04).collect() }; + let w = |dims: &[usize], v: Vec| 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()], + } +} diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs index ae982d30b..bee9929ef 100644 --- a/crates/gpu/src/faces.rs +++ b/crates/gpu/src/faces.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use std::time::Duration; use bytemuck::{Pod, Zeroable}; +use lightcraft_faces::net::program::{Arg, Binary, INPUT, Op, Program, Unary}; use lightcraft_faces::net::{Accelerator, ConvJob}; use wgpu::util::DeviceExt; @@ -35,6 +36,22 @@ struct Dev { tiled: wgpu::ComputePipeline, direct: wgpu::ComputePipeline, reduce: wgpu::ComputePipeline, + ops: Ops, + /// Bound where a kernel has fewer operands than bindings. + dummy: wgpu::Buffer, +} + +/// The kernels of `wgsl/faces_ops.wgsl`. +struct Ops { + unary: wgpu::ComputePipeline, + affine: wgpu::ComputePipeline, + prelu: wgpu::ComputePipeline, + binary: wgpu::ComputePipeline, + transpose: wgpu::ComputePipeline, + maxpool: wgpu::ComputePipeline, + resize: wgpu::ComputePipeline, + gavg: wgpu::ComputePipeline, + gemv: wgpu::ComputePipeline, } static DEV: OnceLock> = OnceLock::new(); @@ -146,20 +163,43 @@ fn make_device(backends: wgpu::Backends) -> Result { source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_conv.wgsl").into()), }); let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); - let pipe = |entry: &str| { + let ops_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("faces ops"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_ops.wgsl").into()), + }); + let pipe_in = |module: &wgpu::ShaderModule, entry: &str| { device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(entry), layout: Some(&pl), - module: &module, + module, entry_point: Some(entry), // both kernels write all of their workgroup memory before reading it compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() }, cache: None, }) }; + let pipe = |entry: &str| pipe_in(&module, entry); + let op = |entry: &str| pipe_in(&ops_module, entry); let (tiled, direct, reduce) = (pipe("tiled"), pipe("direct"), pipe("reduce")); + let ops = Ops { + unary: op("unary"), + affine: op("affine"), + prelu: op("prelu"), + binary: op("binary"), + transpose: op("transpose"), + maxpool: op("maxpool"), + resize: op("resize"), + gavg: op("gavg"), + gemv: op("gemv"), + }; + let dummy = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces unused"), + size: 16, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); log::info!("gpu (faces): {} ({:?})", info.name, info.backend); - Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce }) + Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce, ops, dummy }) } /// The adapter face scans would use ("AMD Radeon RX 7900 XTX (Vulkan)"), or why they cannot use one. Creates the @@ -203,53 +243,151 @@ struct Resident { pub struct FaceGpu { dev: &'static Dev, layers: Mutex>>, + consts: Mutex>, resident: AtomicU64, } /// A GPU accelerator for one face model, or why there is none (GPU off, missing or broken). pub fn accelerator() -> Result, String> { - Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) + Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), consts: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) } fn u32_of(v: usize) -> Result { u32::try_from(v).ok().filter(|v| *v < (1 << 31)).ok_or_else(|| "a layer too large for the GPU kernel".to_string()) } +/// How much of the GPU face work may take: at most `concurrent` runs at once, and busy for at most `share` of the time +/// (after a run of `t`, the next one waits `t · (1 − share) / share`). Set by the background scan's pace. +struct Pace { + concurrent: usize, + share: f32, + running: usize, +} + +static PACE: Mutex = Mutex::new(Pace { concurrent: usize::MAX, share: 1.0, running: 0 }); +static PACE_FREED: std::sync::Condvar = std::sync::Condvar::new(); + +/// Limit the GPU work face models do: `concurrent` runs at once (at least one) and `share` of the time (0.05 to 1). +/// The background face scan sets it from its pace; anything else that runs a face model shares the same limit. +pub fn set_pace(concurrent: usize, share: f32) { + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + p.concurrent = concurrent.max(1); + p.share = if share.is_finite() { share.clamp(0.05, 1.0) } else { 1.0 }; + PACE_FREED.notify_all(); +} + +/// A turn on the GPU under the pace; waits for one. Dropping it gives it back. +struct Turn { + share: f32, + started: std::time::Instant, +} + +impl Turn { + fn take() -> Turn { + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + while p.running >= p.concurrent { + p = PACE_FREED.wait_timeout(p, Duration::from_millis(200)).map(|(g, _)| g).unwrap_or_else(|e| e.into_inner().0); + } + p.running += 1; + Turn { share: p.share, started: std::time::Instant::now() } + } +} + +impl Drop for Turn { + fn drop(&mut self) { + // rest while still holding the turn, so the GPU stays idle for the rest of the period + if self.share < 1.0 { + let rest = self.started.elapsed().mul_f32((1.0 - self.share) / self.share).min(Duration::from_secs(2)); + std::thread::sleep(rest); + } + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + p.running = p.running.saturating_sub(1); + PACE_FREED.notify_one(); + } +} + +/// One dispatch to record: a pipeline, its parameters, its four buffers (three read, the last written) and its +/// workgroup counts. +struct Dispatch { + pipeline: &'static wgpu::ComputePipeline, + params: Vec, + buffers: [wgpu::Buffer; 4], + groups: [u32; 3], +} + +/// Workgroups for `count` items of 256 (or `count` workgroups when `per_group`): x, y and the row length the kernel +/// reads back as `q(1)`. +fn flat(count: usize, limit: usize) -> Option<[u32; 3]> { + let blocks = count.max(1); + let row = blocks.clamp(1, limit.min(65_535)); + let rows = blocks.div_ceil(row); + (rows <= limit).then_some([u32::try_from(row).ok()?, u32::try_from(rows).ok()?, u32::try_from(row).ok()?]) +} + +fn params24(values: &[u32]) -> Vec { + let mut all = [0u32; 24]; + for (to, v) in all.iter_mut().zip(values) { + *to = *v; + } + bytemuck::cast_slice(&all).to_vec() +} + impl FaceGpu { /// Bytes of weights this model keeps on the device. pub fn resident_bytes(&self) -> u64 { self.resident.load(Ordering::Relaxed) } - fn resident(&self, job: &ConvJob) -> Result>, String> { - let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(r) = layers.get(&job.layer) { - return Ok(Some(r.clone())); - } - let bytes = (job.weights.len() as u64 + job.cout as u64) * 4; - if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { - return Ok(None); - } - let d = self.dev; - let usage = wgpu::BufferUsages::STORAGE; - let weights = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("faces weights"), - contents: bytemuck::cast_slice(job.weights), - usage, - }); - let zero = [0f32; 1]; - let bias = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("faces bias"), - contents: bytemuck::cast_slice(job.bias.unwrap_or(&zero)), - usage, - }); - let r = Arc::new(Resident { weights, bias }); - self.resident.fetch_add(bytes, Ordering::Relaxed); - layers.insert(job.layer, r.clone()); - Ok(Some(r)) + fn upload(&self, label: &str, data: &[f32]) -> wgpu::Buffer { + let zero = [0f32; 4]; + self.dev.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytemuck::cast_slice(if data.is_empty() { &zero } else { data }), + usage: wgpu::BufferUsages::STORAGE, + }) } - fn run(&self, job: &ConvJob) -> Result>, String> { + /// Whether `bytes` more may stay on the device; counts them if so. + fn keep(&self, bytes: u64) -> bool { + if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { + return false; + } + self.resident.fetch_add(bytes, Ordering::Relaxed); + true + } + + /// A convolution's weights and bias: kept on the device while the budget lasts, uploaded for this run past it. + fn resident(&self, job: &ConvJob) -> Arc { + let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(r) = layers.get(&job.layer) { + return r.clone(); + } + let r = Arc::new(Resident { weights: self.upload("faces weights", job.weights), bias: self.upload("faces bias", job.bias.unwrap_or(&[])) }); + if self.keep((job.weights.len() as u64 + job.cout as u64) * 4) { + layers.insert(job.layer, r.clone()); + } + r + } + + /// A program's constant, kept on the device like the weights. + fn constant(&self, id: usize, data: &[f32]) -> wgpu::Buffer { + let mut consts = self.consts.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(b) = consts.get(&id) { + return b.clone(); + } + let b = self.upload("faces constant", data); + if self.keep(data.len() as u64 * 4) { + consts.insert(id, b.clone()); + } + b + } + + fn largest_binding(&self) -> u64 { + self.dev.limits.max_storage_buffer_binding_size.min(self.dev.limits.max_buffer_size) + } + + /// The dispatches of one convolution reading `input` and writing `output`, or `None` when it exceeds the device. + fn conv_dispatches(&self, job: &ConvJob, input: &wgpu::Buffer, output: &wgpu::Buffer) -> Result>, String> { let d = self.dev; let groups = job.groups.max(1); let (m, cg) = (job.cout / groups, job.cin / groups); @@ -264,22 +402,15 @@ impl FaceGpu { let kchunk = k.div_ceil(splits).div_ceil(16) * 16; let splits = k.div_ceil(kchunk.max(1)).max(1); let partial = total.checked_mul(splits).ok_or("a layer too large")?; - let largest = [job.input.len(), job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; - if largest > d.limits.max_storage_buffer_binding_size.min(d.limits.max_buffer_size) { - return Ok(None); - } - let flat = |count: usize| { - let blocks = count.div_ceil(256); - let row = blocks.clamp(1, 65_535); - (row, blocks.div_ceil(row), 1, row) - }; - let (wx, wy, wz, row) = if direct { flat(total) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits, flat(total).3) }; - let (rx, ry, _, _) = flat(total); + let cin_len = job.cin.checked_mul(job.h).and_then(|v| v.checked_mul(job.w)).ok_or("a layer too large")?; + let largest = [cin_len, job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; let limit = d.limits.max_compute_workgroups_per_dimension as usize; - if [wx, wy, wz, rx, ry].into_iter().any(|v| v > limit) { + let Some(sum) = flat(total.div_ceil(256), limit) else { return Ok(None) }; + let (wx, wy, wz) = if direct { (sum[0] as usize, sum[1] as usize, 1) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits) }; + if largest > self.largest_binding() || [wx, wy, wz].into_iter().any(|v| v > limit) { return Ok(None); } - let Some(res) = self.resident(job)? else { return Ok(None) }; + let res = self.resident(job); let params = Params { m: u32_of(m)?, n: u32_of(n)?, @@ -296,82 +427,93 @@ impl FaceGpu { pl: u32_of(job.pl)?, bias: u32::from(job.bias.is_some()), total: u32_of(total)?, - row: u32_of(row)?, + row: sum[2], splits: u32_of(splits)?, kchunk: u32_of(kchunk)?, _a: 0, _b: 0, }; - let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; - let scopes = [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)]; + let params = bytemuck::bytes_of(¶ms).to_vec(); + let main = |out: &wgpu::Buffer| Dispatch { + pipeline: if direct { &d.direct } else { &d.tiled }, + params: params.clone(), + buffers: [input.clone(), res.weights.clone(), res.bias.clone(), out.clone()], + groups: [wx as u32, wy as u32, wz as u32], + }; + if splits == 1 { + return Ok(Some(vec![main(output)])); + } + let partials = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces partial sums"), + size: partial as u64 * 4, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + Ok(Some(vec![ + main(&partials), + Dispatch { + pipeline: &d.reduce, + params, + buffers: [partials, res.weights.clone(), res.bias.clone(), output.clone()], + groups: [sum[0], sum[1], 1], + }, + ])) + } + + /// Record `list` into `enc` as one compute pass, with all their parameters in one uniform buffer. + fn record(&self, enc: &mut wgpu::CommandEncoder, list: &[Dispatch]) -> Result<(), String> { + let d = self.dev; + if list.is_empty() { + return Ok(()); + } + let align = (d.limits.min_uniform_buffer_offset_alignment as usize).max(256); + let mut blob = vec![0u8; list.len() * align]; + for (i, x) in list.iter().enumerate() { + blob.get_mut(i * align..i * align + x.params.len()).ok_or("parameters too large")?.copy_from_slice(&x.params); + } let uniform = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("faces params"), - contents: bytemuck::bytes_of(¶ms), + contents: &blob, usage: wgpu::BufferUsages::UNIFORM, }); - let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("faces input"), - contents: bytemuck::cast_slice(job.input), - usage: wgpu::BufferUsages::STORAGE, - }); - let bytes = total as u64 * 4; - let output = d.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("faces output"), - size: bytes, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }); - let partials = (splits > 1).then(|| { - d.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("faces partial sums"), - size: partial as u64 * 4, - usage: wgpu::BufferUsages::STORAGE, - mapped_at_creation: false, + let binds = list + .iter() + .enumerate() + .map(|(i, x)| { + let [a, b, c, out] = &x.buffers; + d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform, + offset: (i * align) as u64, + size: std::num::NonZeroU64::new(x.params.len() as u64), + }), + }, + wgpu::BindGroupEntry { binding: 1, resource: a.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: b.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: c.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: out.as_entire_binding() }, + ], + }) }) - }); - let staging = d.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("faces read-back"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind = d.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &d.layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: input.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: partials.as_ref().unwrap_or(&output).as_entire_binding() }, - ], - }); - let summing = partials.as_ref().map(|partials| { - d.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &d.layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: partials.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: output.as_entire_binding() }, - ], - }) - }); - let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); - { - let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces conv"), timestamp_writes: None }); - pass.set_pipeline(if direct { &d.direct } else { &d.tiled }); - pass.set_bind_group(0, &bind, &[]); - pass.dispatch_workgroups(u32_of(wx)?, u32_of(wy)?, u32_of(wz)?); - if let Some(summing) = &summing { - pass.set_pipeline(&d.reduce); - pass.set_bind_group(0, summing, &[]); - pass.dispatch_workgroups(u32_of(rx)?, u32_of(ry)?, 1); - } + .collect::>(); + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces"), timestamp_writes: None }); + for (x, bind) in list.iter().zip(&binds) { + pass.set_pipeline(x.pipeline); + pass.set_bind_group(0, bind, &[]); + let [gx, gy, gz] = x.groups; + pass.dispatch_workgroups(gx, gy, gz); } - enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + Ok(()) + } + + /// Submit `enc`, which ends by copying into `staging` (`bytes` long), wait for it and read `staging` back. + fn finish(&self, enc: wgpu::CommandEncoder, staging: &wgpu::Buffer, scopes: [wgpu::ErrorScopeGuard; 2]) -> Result, String> { + let d = self.dev; let index = d.queue.submit([enc.finish()]); let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); @@ -389,7 +531,7 @@ impl FaceGpu { return Err(format!("the GPU reported an error: {e}")); } if let Err(e) = waited { - mark_broken(&format!("the GPU did not finish a convolution: {e}")); + mark_broken(&format!("the GPU did not finish a face model: {e}")); return Err(format!("the GPU did not finish: {e}")); } match rx.recv_timeout(Duration::from_secs(10)) { @@ -402,8 +544,244 @@ impl FaceGpu { data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::>() }; staging.unmap(); - Ok(Some(out)) + Ok(out) } + + fn scopes(&self) -> [wgpu::ErrorScopeGuard; 2] { + [self.dev.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), self.dev.device.push_error_scope(wgpu::ErrorFilter::Validation)] + } + + fn staging(&self, bytes: u64) -> wgpu::Buffer { + self.dev.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces read-back"), + size: bytes.max(4), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + } + + fn scratch(&self, bytes: u64) -> wgpu::Buffer { + self.dev.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces values"), + size: bytes.max(4), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) + } + + /// One convolution, uploaded and read back on its own. + fn run(&self, job: &ConvJob) -> Result>, String> { + let total = job.cout.checked_mul(job.oh).and_then(|v| v.checked_mul(job.ow)).ok_or("a layer too large")?; + let bytes = total as u64 * 4; + if bytes > self.largest_binding() || job.input.len() as u64 * 4 > self.largest_binding() { + return Ok(None); + } + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let _turn = Turn::take(); + let d = self.dev; + let scopes = self.scopes(); + let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(job.input), + usage: wgpu::BufferUsages::STORAGE, + }); + let output = self.scratch(bytes); + let Some(list) = self.conv_dispatches(job, &input, &output)? else { + // leave the scopes balanced + let [oom, validation] = scopes; + let _ = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + return Ok(None); + }; + let staging = self.staging(bytes); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + self.record(&mut enc, &list)?; + enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + self.finish(enc, &staging, scopes).map(Some) + } + + /// The dispatch of one non-convolution step. + fn op_dispatch(&self, op: &Op, out_len: usize, args: [wgpu::Buffer; 3], out: &wgpu::Buffer) -> Result, String> { + let d = self.dev; + let limit = d.limits.max_compute_workgroups_per_dimension as usize; + let u = u32_of; + let n = u(out_len)?; + let per_value = |pipeline: &'static wgpu::ComputePipeline, rest: &[u32]| -> Option { + let groups = flat(out_len.div_ceil(256), limit)?; + let mut all = vec![n, groups[2]]; + all.extend_from_slice(rest); + Some(Dispatch { + pipeline, + params: params24(&all), + buffers: [args[0].clone(), args[1].clone(), args[2].clone(), out.clone()], + groups: [groups[0], groups[1], 1], + }) + }; + let per_group = |pipeline: &'static wgpu::ComputePipeline, rest: &[u32]| -> Option { + let groups = flat(out_len, limit)?; + let mut all = vec![n, groups[2]]; + all.extend_from_slice(rest); + Some(Dispatch { + pipeline, + params: params24(&all), + buffers: [args[0].clone(), args[1].clone(), args[2].clone(), out.clone()], + groups: [groups[0], groups[1], 1], + }) + }; + let four = |v: &[usize; 4]| -> Result<[u32; 4], String> { Ok([u(v[0])?, u(v[1])?, u(v[2])?, u(v[3])?]) }; + let k = &d.ops; + Ok(match op { + Op::Conv(_) => None, + Op::Unary(f) => per_value(&k.unary, &[u32::from(*f == Unary::Sigmoid)]), + Op::ChannelAffine { channels, plane } => per_value(&k.affine, &[u(*plane)?, u(*channels)?]), + Op::ChannelPrelu { channels, plane } => per_value(&k.prelu, &[u(*plane)?, u(*channels)?]), + Op::Binary { op, dims, a, b } => { + let kind = match op { + Binary::Add => 0, + Binary::Sub => 1, + Binary::Mul => 2, + }; + let mut rest = vec![kind]; + rest.extend(four(dims)?); + rest.extend(four(a)?); + rest.extend(four(b)?); + per_value(&k.binary, &rest) + } + Op::Transpose { dims, strides } => { + let mut rest = vec![0]; + rest.extend(four(dims)?); + rest.extend(four(strides)?); + per_value(&k.transpose, &rest) + } + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => { + per_value(&k.maxpool, &[u(*h)?, u(*w)?, u(*kh)?, u(*kw)?, u(*sh)?, u(*sw)?, u(*oh)?, u(*ow)?]) + } + Op::Resize { h, w, sh, sw, .. } => per_value(&k.resize, &[u(*h)?, u(*w)?, u(*sh)?, u(*sw)?]), + Op::GlobalAverage { plane, .. } => per_group(&k.gavg, &[u(*plane)?]), + Op::Gemv { k: kk, n: nn, trans_b, alpha } => per_group(&k.gemv, &[u(*kk)?, u(*nn)?, u32::from(*trans_b), alpha.to_bits()]), + }) + } + + /// A whole program for each input, every value staying on the device; one submission and one read-back. + fn program(&self, prog: &Program, inputs: &[&[f32]]) -> Result>>>, String> { + let d = self.dev; + let largest = self.largest_binding(); + if prog.lens.iter().any(|l| *l as u64 * 4 > largest) || inputs.iter().any(|i| Some(&i.len()) != prog.lens.get(INPUT)) { + return Ok(None); + } + let out_bytes: u64 = prog.outputs.iter().map(|(_, v, _)| prog.lens.get(*v).copied().unwrap_or(0) as u64 * 4).sum(); + let total_bytes = out_bytes.checked_mul(inputs.len() as u64).ok_or("too many faces at once")?; + if total_bytes > d.limits.max_buffer_size { + return Ok(None); + } + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let _turn = Turn::take(); + let scopes = self.scopes(); + let staging = self.staging(total_bytes); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("faces program") }); + // buffers nothing reads any more, reused for later values (best fit) + let mut pool: Vec<(u64, wgpu::Buffer)> = Vec::new(); + let take = |pool: &mut Vec<(u64, wgpu::Buffer)>, bytes: u64| -> wgpu::Buffer { + let best = pool + .iter() + .enumerate() + .filter(|(_, (size, _))| *size >= bytes && *size <= bytes.saturating_mul(2)) + .min_by_key(|(_, (size, _))| *size) + .map(|(i, _)| i); + match best { + Some(i) => pool.swap_remove(i).1, + None => self.scratch(bytes), + } + }; + let mut at = 0u64; + let mut fits = true; + for input in inputs { + let mut values: Vec> = vec![None; prog.lens.len()]; + // a fresh buffer: queued writes land before any of the encoder's work, so a pooled one could still be in use + let first = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(input), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + }); + if let Some(v) = values.get_mut(INPUT) { + *v = Some((input.len() as u64 * 4, first)); + } + let mut list = Vec::new(); + for step in &prog.steps { + let len = prog.lens.get(step.out).copied().ok_or("a step writes a value the program does not have")?; + let out = take(&mut pool, len as u64 * 4); + let mut args = Vec::with_capacity(3); + for a in &step.args { + args.push(match a { + Arg::Value(v) => { + values.get(*v).and_then(|v| v.as_ref()).map(|(_, b)| b.clone()).ok_or("a step reads a value before it is made")? + } + Arg::Const { id, data } => self.constant(*id, data), + }); + } + let dispatches = match &step.op { + Op::Conv(job) => { + let input = args.first().ok_or("a convolution without input")?; + self.conv_dispatches(job, input, &out)? + } + op => { + let arg = |i: usize| args.get(i).cloned().unwrap_or_else(|| d.dummy.clone()); + self.op_dispatch(op, len, [arg(0), arg(1), arg(2)], &out)?.map(|x| vec![x]) + } + }; + let Some(dispatches) = dispatches else { + fits = false; + break; + }; + list.extend(dispatches); + if let Some(v) = values.get_mut(step.out) { + *v = Some((len as u64 * 4, out)); + } + for f in &step.free { + if let Some(b) = values.get_mut(*f).and_then(Option::take) { + pool.push(b); + } + } + } + if !fits { + break; + } + self.record(&mut enc, &list)?; + for (_, v, _) in &prog.outputs { + let (bytes, buffer) = values.get(*v).and_then(|v| v.as_ref()).ok_or("an output was never made")?; + let len = prog.lens.get(*v).copied().unwrap_or(0) as u64 * 4; + enc.copy_buffer_to_buffer(buffer, 0, &staging, at, len.min(*bytes)); + at += len; + } + pool.extend(values.into_iter().flatten()); + } + if !fits { + let [oom, validation] = scopes; + let _ = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + return Ok(None); + } + let flat_out = self.finish(enc, &staging, scopes)?; + let mut rest = flat_out.as_slice(); + let mut per_input = Vec::with_capacity(inputs.len()); + for _ in inputs { + let mut outs = Vec::with_capacity(prog.outputs.len()); + for (_, v, _) in &prog.outputs { + let len = prog.lens.get(*v).copied().unwrap_or(0); + let (this, next) = rest.split_at_checked(len).ok_or("the read-back is short")?; + outs.push(this.to_vec()); + rest = next; + } + per_input.push(outs); + } + Ok(Some(per_input)) + } +} + +/// Run `f`, turning a panic into a broken device. +fn guarded(f: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| { + mark_broken("a face model on the GPU panicked"); + Err("the GPU runner gave up".into()) + }) } impl Accelerator for FaceGpu { @@ -418,13 +796,23 @@ impl Accelerator for FaceGpu { if crate::switched_off().is_some() || job.macs() < MIN_MACS { return Ok(None); } - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run(job))).unwrap_or_else(|_| { - mark_broken("a face convolution panicked"); - Err("the GPU runner gave up".into()) - }) + guarded(|| self.run(job)) + } + + fn run_program(&self, program: &Program, inputs: &[&[f32]]) -> Result>>>, String> { + if let Some(why) = broken_reason() { + return Err(format!("the GPU is not used any more: {why}")); + } + if crate::switched_off().is_some() || PER_CONV.load(Ordering::Relaxed) { + return Ok(None); + } + guarded(|| self.program(program, inputs)) } } +/// Tests and benchmarks only: run convolutions one by one (the first step's way) instead of whole programs. +static PER_CONV: AtomicBool = AtomicBool::new(false); + #[cfg(test)] mod tests { use super::*; @@ -513,6 +901,28 @@ mod tests { assert!(gpu.resident_bytes() > 0, "the weights stay on the device"); } + #[test] + fn every_operator_on_the_gpu_gives_what_the_cpu_gives() { + let Some(gpu) = gpu() else { return }; + let net = lightcraft_faces::net::Net::new(lightcraft_faces::synthetic::every_operator_graph()).unwrap(); + let inputs: Vec = (0..3) + .map(|s| { + lightcraft_faces::net::Tensor::new(vec![1, 4, 8, 8], (0..256).map(|i| (((i * 31 + s * 7) % 37) as f32 - 18.0) * 0.05).collect()) + .unwrap() + }) + .collect(); + let cpu: Vec<_> = inputs.iter().map(|x| net.run(x.clone()).unwrap()).collect(); + let program = net.program(&[1, 4, 8, 8]).unwrap().unwrap(); + let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect(); + let got = gpu.program(&program, &slices).unwrap().unwrap(); + for (want, got) in cpu.iter().zip(&got) { + for ((name, want), got) in want.iter().zip(got) { + let worst = want.data.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(worst < 1e-4, "{name}: off by {worst}"); + } + } + } + /// The real models on the GPU agree with the CPU interpreter: `LC_FACE_MODELS=` holding /// `face_detection_yunet_2023mar.onnx`, `face_recognition_sface_2021dec.onnx` and (optionally) `glintr100.onnx`. #[test] @@ -555,7 +965,8 @@ mod tests { } } - /// Timings, CPU against GPU: `LC_FACE_MODELS= cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. + /// Timings, CPU against the GPU one convolution at a time and as whole programs (one face, and a batch of 16): + /// `LC_FACE_MODELS= cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. #[test] #[ignore = "a benchmark: needs LC_FACE_MODELS and a GPU"] fn timings() { @@ -573,21 +984,35 @@ mod tests { let (w, h) = (1024, 768); let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); let opts = lightcraft_faces::yunet::Options::default(); - let cpu = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + let cpu = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + PER_CONV.store(true, Ordering::Relaxed); det.accelerate(accelerator().unwrap(), false).unwrap(); - let fast = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); - eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + let per_conv = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + PER_CONV.store(false, Ordering::Relaxed); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let whole = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + assert!(det.net().accelerator().is_some()); + eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU per convolution {per_conv:.1} ms, GPU whole network {whole:.1} ms"); for (file, sha) in [ ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), ] { let e = lightcraft_faces::runtime::Embedder::load(&dir.join(file), &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); let (ew, eh) = e.input_size(); - let face: Vec = (0..ew * eh * 3).map(|i| (i * 13 % 251) as u8).collect(); - let cpu = time(3, &mut || drop(e.embed(&face).unwrap())); + let faces: Vec> = (0..16).map(|s| (0..ew * eh * 3).map(|i| ((i + s * 101) * 13 % 251) as u8).collect()).collect(); + let refs: Vec<&[u8]> = faces.iter().map(Vec::as_slice).collect(); + let cpu = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + PER_CONV.store(true, Ordering::Relaxed); e.accelerate(accelerator().unwrap(), false).unwrap(); - let fast = time(3, &mut || drop(e.embed(&face).unwrap())); - eprintln!("{file}: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + let per_conv = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + PER_CONV.store(false, Ordering::Relaxed); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let whole = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + let batch = time(3, &mut || drop(e.embed_batch(&refs))) / 16.0; + assert!(e.net().accelerator().is_some()); + eprintln!( + "{file}: CPU {cpu:.1} ms, GPU per convolution {per_conv:.1} ms, GPU whole network {whole:.1} ms, batch of 16 {batch:.1} ms a face" + ); } } } diff --git a/crates/gpu/src/wgsl/faces_ops.wgsl b/crates/gpu/src/wgsl/faces_ops.wgsl new file mode 100644 index 000000000..aa7eba242 --- /dev/null +++ b/crates/gpu/src/wgsl/faces_ops.wgsl @@ -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, 6>, +} + +@group(0) @binding(0) var p: P; +@group(0) @binding(1) var a: array; +@group(0) @binding(2) var b: array; +@group(0) @binding(3) var c: array; +@group(0) @binding(4) var dst: array; + +fn q(i: u32) -> u32 { + return p.v[i / 4u][i % 4u]; +} + +fn index(wid: vec3, 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, @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, @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, @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, @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, @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, @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, @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 partial: array; + +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, @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, @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(q(5)) * partial[0] + c[o]; + } +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..bfaf57fdf 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,13 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..d153c911e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,13 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "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" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..3827ce3ac 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,13 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..d9d83d109 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,13 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "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 を確認中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..f626e6433 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,13 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..f08c3cb7d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,13 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "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": "Проверка ГП для лиц" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..a0783aa5f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,13 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "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": "Перевірка ГП для облич" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..c2ef31084 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,13 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "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" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..b830bf434 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,13 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "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" } diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..27933f53d 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -157,6 +157,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 LccApp, 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) -> String { match bytes { @@ -231,6 +267,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let all: Vec = 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); diff --git a/docs/faces.md b/docs/faces.md index f4f4a4757..c767ce913 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -145,31 +145,40 @@ names alone do not guarantee the same speed, and those additional model files we ### On the GPU -On a machine with a usable GPU, the convolutions of the detector and the recogniser (most of their time) run on it -through wgpu (`crates/gpu/src/faces.rs`, `wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input -straight into workgroup memory, cuts K into slices when a layer's map is small, and a direct kernel for depthwise -layers). Everything else in the graph stays on the CPU interpreter, which remains the reference. After a model loads, -a background check runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when +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), missing, -wrong or slower, the model runs on the CPU. The first GPU error drops it for that model. 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 log says which -device each model runs on. +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. -Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under heavy load from other -jobs (so the absolute numbers are noisy; the ratios held across three runs): +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. -| Model | CPU | GPU | -| --- | ---: | ---: | -| YuNet (640², one photo) | 33–34 ms | 35–39 ms | -| SFace 2021dec | 25–28 ms | 22–27 ms | -| AuraFace v1 | 204–208 ms | 81–103 ms | +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 | -Each layer still goes to the GPU and back: for YuNet and SFace that round trip costs about what the layer saves, so on -most machines they stay on the CPU by the speed check, while AuraFace runs 2–2.5× faster. Keeping activations on the GPU -between layers (the remaining operators as kernels) and batching faces per dispatch are the next steps. `LC_FACE_MODELS= cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks -agreement on the real models and prints these timings. +agreement on the real models and every operator, and prints these timings. ### Agreement and regression coverage From 61ec5c596c0eea9acbc536778379dd26c2b1a675 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Wed, 14 Oct 2026 20:02:09 +1100 Subject: [PATCH 07/20] Docs: restore upstream's header notes in gaps and parity checklist Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 052dff174..61e0ac7e8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 4 closed: soft proofing gets zoom windows) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 4 closed: soft proofing gets zoom windows; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 93601c0ac..9c59dede6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good From 540a281ccda0f2dce928e630e5f43caa37271db3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 13 Oct 2026 12:49:11 -0400 Subject: [PATCH 08/20] PERF-SYNC: a Synchronize Folder scan reads off the folder's listing whether a photo and its sidecar are there The scan asked the disk about every library photo in the folder: is its file there, and has it a sidecar under any of four names. On a network share each question is a round trip, and the scan had just listed the folder. Measured on a hard disk over SMB, for unchanged folders of 1,600 to 2,400 photos: 4.6 to 7.1 s of questions after a listing of 4 to 5 s. - The walk hands each folder's listing over (`walk::files_in_noting`: every entry's name and kind, hidden ones too, and whether that is all of them). The scan walks the folder once, then checks the photos beside the probing of new files. - `sync::Listing` answers "is it there" / "is it a file" where that holds on any file system: a name the folder lists is there; a plain ASCII name is not there when the folder was listed to its end, all its names are ASCII and none is that name in another case. Everything else is asked of the disk as before (a link, a name Windows reads another way, a folder that was not walked, and every name not listed in a folder that holds a name outside ASCII). Now under 0.05 s for those folders. Two things differ on purpose: a file the folder lists is there though it can't be opened by its path (a folder that may be listed but not searched, an over-long path), where such photos were listed as missing; and the answers are the folder as the walk saw it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/examples/sync_scan_bench.rs | 47 ++++++ crates/engine/src/import.rs | 30 +++- crates/engine/src/sidecar.rs | 19 ++- crates/engine/src/sync.rs | 179 ++++++++++++++++++-- crates/engine/src/sync/tests_listing.rs | 191 ++++++++++++++++++++++ crates/engine/src/walk.rs | 97 ++++++++++- docs/whats-new.md | 17 ++ 7 files changed, 553 insertions(+), 27 deletions(-) create mode 100644 crates/engine/examples/sync_scan_bench.rs create mode 100644 crates/engine/src/sync/tests_listing.rs diff --git a/crates/engine/examples/sync_scan_bench.rs b/crates/engine/examples/sync_scan_bench.rs new file mode 100644 index 000000000..3b9bc51c1 --- /dev/null +++ b/crates/engine/examples/sync_scan_bench.rs @@ -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 -- ` +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 "); + 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() + ); + } +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e..821454c72 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -380,6 +380,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { /// [`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, 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, +) -> (Vec, bool) { let mut out = Vec::new(); let mut truncated = false; for p in paths { @@ -388,7 +399,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)", @@ -630,6 +644,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 { std::sync::Arc::clone(&self.library) @@ -648,9 +667,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec { /// [`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, progress: &ScanProgress) -> ScanOutput { + use std::sync::atomic::Ordering::Relaxed; let by_path = library_files(&input.library, &files); let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); progress.total.store(todo.len(), Relaxed); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..41d1c5f57 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -65,15 +65,18 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf { /// An existing sidecar for `original`: the preferred naming first, then the other one (also /// accepting an upper-case `.XMP`). pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { + 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, each as `.xmp` and as `.XMP`. +pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator { let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; - [naming, other] - .into_iter() - .flat_map(|n| { - let p = sidecar_path(original, n); - let upper = p.with_extension("XMP"); - [p, upper] - }) - .find(|p| p.is_file()) + [naming, other].into_iter().flat_map(move |n| { + let p = sidecar_path(original, n); + let upper = p.with_extension("XMP"); + [p, upper] + }) } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 4342ec6ad..6f236796e 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -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, 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, + /// 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, + /// 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, + /// 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> { + 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 { + 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 { + 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, 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, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec { +fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec { 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; diff --git a/crates/engine/src/sync/tests_listing.rs b/crates/engine/src/sync/tests_listing.rs new file mode 100644 index 000000000..e54ed76ef --- /dev/null +++ b/crates/engine/src/sync/tests_listing.rs @@ -0,0 +1,191 @@ +//! 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 { + 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#""#; + 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> = + ["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::>(), 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 four names of the + // sidecar `plain.jpg` has not, the two `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, 9, "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); +} diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 1c1b53b4a..f8a3ba251 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -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) -> 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 = 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"); diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..591513d84 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -53,6 +53,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 From af0c1638fe6070c6e3849e3ef9c60fe3d15c5665 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:33:26 +0400 Subject: [PATCH 09/20] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Georgian (ქართული, code ka, system tags ka / ka-GE / ka_GE) with complete plain and formats catalogs, the Edit > Language command and the Settings choice. Sentence-cased messages go through i18n::upper_first, which leaves Georgian letters alone (upper-casing Mkhedruli would turn it into Mtavruli). The Settings language choices wrap now that eleven endonyms no longer fit on one line, and the face-model Remove button goes through tr(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- README.md | 2 +- ROADMAP.md | 5 +- assets/ATTRIBUTION.md | 1 + crates/ui-egui/locales/ka-formats.json | 251 ++ crates/ui-egui/locales/ka.json | 2721 +++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 52 + crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/panels/denoise.rs | 4 +- crates/ui-egui/src/panels/faces.rs | 6 +- crates/ui-egui/src/panels/keyword_list.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 6 +- docs/localization-ka.md | 77 + docs/localization-parity.md | 8 +- docs/localization.md | 9 +- docs/parity-checklist.md | 4 +- docs/roadmap.md | 2 +- 16 files changed, 3132 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/locales/ka-formats.json create mode 100644 crates/ui-egui/locales/ka.json create mode 100644 docs/localization-ka.md diff --git a/README.md b/README.md index f041111a1..5dc19fe73 100644 --- a/README.md +++ b/README.md @@ -405,7 +405,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) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, ქართული) 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 diff --git a/ROADMAP.md b/ROADMAP.md index 3f02a1199..38e3413ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Georgian interface language) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -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%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Georgian (100%). ## Upcoming @@ -163,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..9f3edd4ba 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -26,6 +26,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | | craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | | `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/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 diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json new file mode 100644 index 000000000..719aee83f --- /dev/null +++ b/crates/ui-egui/locales/ka-formats.json @@ -0,0 +1,251 @@ +{ + " ({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}" +} diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json new file mode 100644 index 000000000..cfb845f56 --- /dev/null +++ b/crates/ui-egui/locales/ka.json @@ -0,0 +1,2721 @@ +{ + " (library default)": " (ბიბლიოთეკის ნაგულისხმევი)", + " (inverted)": " (ინვერსიული)", + " · best of its burst": " · საუკეთესო თავის სერიაში", + " · in a burst": " · სერიაში", + "\"Albums\" header takes it back to the top level; Esc cancels; holding a dragged album or photos near the top or bottom edge of the sidebar scrolls it). Drop on the top or bottom half of another album,": "„ალბომების“ სათაური მას ზედა დონეზე აბრუნებს; Esc აუქმებს; გადათრეული ალბომის ან ფოტოების გვერდითი ზოლის ზედა ან ქვედა კიდესთან დაჭერა მას აგორებს). ჩააგდეთ სხვა ალბომის ზედა ან ქვედა ნახევარზე,", + "\"File exists\".": "„File exists“.", + "\"HEIC/HEIF support isn't included in this build\" for each `.heic`/`.heif` at import.": "იმპორტისას ყოველ `.heic`/`.heif`-ზე იტყობინება: „HEIC/HEIF-ის მხარდაჭერა ამ აწყობაში არ შედის“.", + "\"Rendering…\" on the photo": "„რენდერდება…“ ფოტოზე", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„მეხსიერებაში შენახულია, მაგრამ დისკზე არ ჩაწერილა“, ზედა ზოლი აჩვენებს გაფრთხილებას და LightCraft ცდილობს ხელახლა, სანამ", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): DNG მოწმდება RAW მონაცემებთან, იწერება დროებით ფაილში, სინქრონიზდება და ხელახლა იკითხება, სანამ მიიღებს", + "% of all commits": "ყველა კომიტის %", + "& Containing": "და შემცველი", + "(\"Failed to associate portal window with parent window\" in the portal's log): tiling compositors put it beside the": "(„Failed to associate portal window with parent window“ პორტალის ჟურნალში): ფილისებრი კომპოზიტორები მას", + "(Esc keeps the old angle; a decimal comma works too), or click the Straighten value; hovering any slider's value now says it can be typed.": "(Esc ძველ კუთხეს ტოვებს; ათწილადი მძიმეც მუშაობს), ან დაწკაპეთ გასწორების მნიშვნელობაზე; ნებისმიერი სლაიდერის მნიშვნელობაზე მიტანისას ახლა ჩანს, რომ მისი აკრეფა შეიძლება.", + "(Space, G, X, ⌫) and ⌘ shortcuts were not affected.": "(Space, G, X, ⌫) და ⌘ მალსახმობებზე ეს გავლენას არ ახდენდა.", + "(`album.setRules` takes `name`).": "(`album.setRules` იღებს `name`-ს).", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), დახმარება → სისტემის ინფორმაცია და პარამეტრები → წარმადობა განმარტავს, რატომ არ არის GPU", + "(`id`, `parent?`, `before?`) and `album.sort`.": "(`id`, `parent?`, `before?`) და `album.sort`-ს.", + "(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a": "(`render in.raw -o out.jxl`). ხარისხი — როგორც JPEG-ისთვის, ან უდანაკარგო; ძალისხმევა (სწრაფი / ჩვეულებრივი) დროს ცვლის", + "(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.": "(შემოწმებულია ერთ CC0 iPhone 12 Pro ProRAW-ზე). მატების გარეშე ფოტოები უცვლელია.", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(მაგ., D750, D780, D850, D7500, Z 50) აღარ გამოისახება თითქმის შავად ან ჩახშობილი ჩრდილებით: მათი შავის დონე", + "(folders open on click) or finds them as you type part of a name or folder (↑ / ↓ and Enter to pick); albums that": "(საქაღალდეები დაწკაპუნებით იხსნება), ან პოულობს მათ, როცა სახელის ან საქაღალდის ნაწილს აკრეფთ (↑ / ↓ და Enter ასარჩევად); ალბომები, რომელთა", + "(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile": "(ხარვეზი #256): ფაილში კამერის მიერ ჩაწერილი კორექცია გამოიყენება ობიექტივის კორექციები → პროფილის", + "(issue #706; before, the import skipped it as \"already in the library\" while the grid showed it deleted). The": "(ხარვეზი #706; ადრე იმპორტი მას გამოტოვებდა როგორც „უკვე ბიბლიოთეკაშია“, ბადე კი წაშლილად აჩვენებდა).", + "(issue #735).": "(ხარვეზი #735).", + "(issue #782).": "(ხარვეზი #782).", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(ხარვეზი #94): ვებგვერდიდან გაგზავნილ HTTP მოთხოვნას ტანში ბრძანების გადაცემა აღარ შეუძლია. სტრიქონები შეზღუდულია", + "(issues #187, #298). Importing files that are in Recently Deleted asks whether to leave them there, restore them": "(ხარვეზები #187, #298). ახლახან წაშლილში მყოფი ფაილების იმპორტისას კითხულობს, დარჩეს ისინი იქ, აღდგეს", + "(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports": "(macOS, Windows, Linux, FreeBSD, Nix) ახლა მართლაც HEIC-ის მხარდაჭერით არის აწყობილი; მის გარეშე აწყობა", + "(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.": "(მათი ცვლილებებით) თუ ახლად იმპორტირდეს; ნაგვის ხედის ფოტოს მენიუს აქვს ახლახან წაშლილის გასუფთავება.", + "(~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms.": "(~0.6 წმ სლაიდერის თითო ბიჯზე); ახლა სია მხოლოდ ცვლილების შემდეგ ინახება და სლაიდერის ბიჯი ~2 მწმ-ს იკავებს.", + ")": ")", + "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-ის კატალოგის იმპორტი…** ამ კატალოგით, ან გაუქმებას. `lightcraft-cli --library` ასეთ საქაღალდეებზეც უარს ამბობს.", + "+ Group": "+ ჯგუფი", + "+ Rule": "+ წესი", + ", picked": ", რჩეული", + ", rejected": ", უარყოფილი", + "0: nothing is rejected.": "0: არაფერი უარიყოფა.", + "1 day": "1 დღე", + "1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so": "1200D (Rebel T5) და 100D (Rebel SL1) კამერების CR2 ფაილებში ახლა მოკვეთილად ითვლება (ხარვეზი #1272). ადრე ისინი თეთრის 81–98%-ზე ჩერდებოდა, ამიტომ", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 studio-mac-ზე)“) იმის ნაცვლად, რომ ორივემ ჩაწეროს და შეუმჩნევლად წაშალოს ერთმანეთის ცვლილებები. ავარია არასოდეს ტოვებს", + "12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off": "12–32 მმ კიტ-ზუმი ადრე კუთხეებში გამოსახულების სიგანის დაახლოებით 5 %-ით სცდებოდა. კორექციის გამორთვით გადაღებული ფაილები", + "16-bit Rec. 2020 PQ. Not in the web version yet.": "16-ბიტიანი Rec. 2020 PQ. ვებ-ვერსიაში ჯერ არ არის.", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 წინასწარი ხედი და იმპორტირდება მეტამონაცემებით: გადაღების დრო, კამერა, ობიექტივი, ექსპოზიცია, GPS და XMP. მათი RAW", + "2026-04 or 2026-04-12": "2026-04 ან 2026-04-12", + "2026-10-02": "2026-10-02", + "2026-10-05": "2026-10-05", + "2026-10-07": "2026-10-07", + "2026-10-08": "2026-10-08", + "2026-10-09": "2026-10-09", + "2026-10-10": "2026-10-10", + "2026-10-11": "2026-10-11", + "2026-10-12": "2026-10-12", + "2026-10-13": "2026-10-13", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 ახალი ჩაშენებული პრესეტი: პორტრეტი, პეიზაჟი, ქალაქი, საკვები, სეზონები, ვინტაჟი და შავ-თეთრი ტონირება.", + "3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative": "3396 K-ს, „ჩრდილის“ პრესეტით გადაღებული — 7493 K-ს. როგორც #730-ში, იცვლება მხოლოდ რიცხვები: ძველ ფარდობით", + "4 MiB and connections at 16.": "4 MiB-ით, ხოლო კავშირები — 16-ით.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630 დრაივერები): Windows-ზე ფანჯარა და GPU რენდერირება მხოლოდ DirectX 12-ს იყენებს და Vulkan-ის დრაივერს არ ტვირთავს, თუ", + "= exactly": "= ზუსტად", + "A Folders section in the sidebar lists the folders your photos were imported from; choose one to see its photos.": "გვერდით ზოლში საქაღალდეების სექცია ჩამოთვლის საქაღალდეებს, საიდანაც ფოტოები იმპორტირდა; აირჩიეთ ერთი მისი ფოტოების სანახავად.", + "A blank template keeps the original names.": "ცარიელი შაბლონი ორიგინალ სახელებს ინარჩუნებს.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "ცვლილება, რომლის დისკზე შენახვაც ვერ ხერხდება (სავსე ან გამორთული დისკი), აღარ რჩება შეუმჩნეველი: ბრძანება იტყობინება", + "A dialog taller than the window no longer runs off it (issue #781): on a 1366×768 screen the Export dialog's title": "ფანჯარაზე მაღალი დიალოგი აღარ გადის მის საზღვრებს გარეთ (ხარვეზი #781): 1366×768 ეკრანზე ექსპორტის დიალოგის სათაური", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "ფილტრი მალავს ამ ხედის ფოტოებს: შეცვალეთ ან გაასუფთავეთ იგი (ხედი → ფილტრების გასუფთავება)", + "A folder is already being synchronized": "საქაღალდე უკვე სინქრონიზდება", + "A folder is being synchronized": "საქაღალდე სინქრონიზდება", + "A keyword set needs a name": "საკვანძო სიტყვების ნაკრებს სახელი სჭირდება", + "A label follows its folder when it is renamed or moved in LightCraft, together with the labels of the folders": "იარლიყი თავის საქაღალდეს მიჰყვება, როცა ის LightCraft-ში გადაერქმევა ან გადაიტანება, მასში მყოფი საქაღალდეების", + "A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB": "ბიბლიოთეკა ათასობით პრესეტით (მთლიანად იმპორტირებული Lightroom-ის კოლექცია: 11 000 პრესეტი, 42 MB-იანი", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "ბიბლიოთეკა ერთდროულად მხოლოდ ერთ პროგრამაშია გახსნილი (ხარვეზი #99): ბიბლიოთეკის გახსნა, რომელიც LightCraft-ს ან `lightcraft-cli`-ს უკვე", + "A one-time download of about": "ერთჯერადი ჩამოტვირთვა, დაახლოებით", + "A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails": "ფოტო, რომელიც რენდერისას LightCraft-ის ხარვეზს აწყდება (RAW დეკოდერში ან რედაქტირების კონვეიერში), ახლა ისე ვერ იხსნება,", + "A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW": "პრესეტი, რომლის ობიექტივის პროფილის გადამრთველი გამორთულია, აღარ თიშავს DNG-ში ჩაშენებულ ობიექტივის კორექციებს (iPhone ProRAW", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "შენახვა, რომელიც შუა გზაზე წყდება (სავსე დისკი, გათიშული ქსელური საქაღალდე), აღარ ჰგავს დაზიანებულ კატალოგს", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "მძინარე NAS, გათიშული ქსელური საქაღალდე ან ამუშავების პროცესში მყოფი USB დისკი ფანჯარას აღარ ყინავს (ხარვეზი #104):", + "A smart album can include or exclude another smart album: \"Keywords contain travel\" and \"Album isn't Excluded Photos\"": "სმარტ-ალბომს შეუძლია სხვა სმარტ-ალბომის ჩართვა ან გამორიცხვა: „საკვანძო სიტყვები შეიცავს travel“ და „ალბომი არ არის Excluded Photos“", + "A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip": "სმარტ-ალბომი, რომლის წესებიც აღარ მოწმდება (მათ მიერ შემოწმებული ალბომი წაიშალა), გვერდით ზოლში ⚠-ით აღინიშნება; მისი მინიშნება", + "A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file": "დეტალურ ხედში გახსნილი ფოტოს ესკიზი მისი უკვე დეკოდირებული წინასწარი ხედიდან კეთდება, ნაცვლად იმისა, რომ ფაილი", + "A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no": "გადიდებული ხედი ორიგინალის GPU ასლს ინარჩუნებს, სანამ ის ეკრანზეა: გადაადგილებებს შორის რამდენიმეწამიანი პაუზა", + "AI Denoise": "AI ხმაურის მოცილება", + "AI Denoise Model": "AI ხმაურის მოცილების მოდელი", + "AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working": "AI ხმაურის მოცილება და დაკარგული ფოტოების მოძებნა. თითოეულ სტრიქონს აქვს ამოცანის სახელი, პროგრესის ზოლი, რაოდენობა და ის, რაზეც", + "AI Denoise is enabled on the requesting photo": "AI ხმაურის მოცილება ჩართულია მომთხოვნ ფოტოზე", + "AI Denoise is off: press Use on a model.": "AI ხმაურის მოცილება გამორთულია: მოდელზე დააჭირეთ „გამოყენებას“.", + "AI Denoise needs a model. Install one in Settings.": "AI ხმაურის მოცილებას მოდელი სჭირდება. დააინსტალირეთ პარამეტრებში.", + "AI Mask Model Status": "AI ნიღბების მოდელის მდგომარეობა", + "AI RAW denoise": "RAW-ის AI ხმაურის მოცილება", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "AI ნიღბები SAM 3-ით (ობიექტი და აღწერა ნიღბვის პანელში): დაწკაპეთ ობიექტზე მის მოსანიშნად (⌥-დაწკაპუნება ტოვებს", + "ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come": "ARW) ფოტოების გაერთიანება გაერთიანებულ DNG-ს აძლევს იმ ფერს, რომელიც თითო ფოტოს ცალკე გახსნისას აქვს (ხარვეზი #965). CR2-ების HDR გაერთიანება ადრე", + "About": "პროგრამის შესახებ", + "About LightCraft": "LightCraft-ის შესახებ", + "Accept & Download": "მიღება და ჩამოტვირთვა", + "Active": "აქტიური", + "Activity": "აქტივობა", + "Activity stack": "აქტივობების დასტა", + "Actual size": "რეალური ზომა", + "Add": "დამატება", + "Add Catchlight": "თვალის ანარეკლის დამატება", + "Add Face Model…": "სახეების მოდელის დამატება…", + "Add Folder to Local": "საქაღალდის ლოკალურში დამატება", + "Add Mask": "ნიღბის დამატება", + "Add Object Click": "ობიექტის დაწკაპუნების დამატება", + "Add Point Color Sample": "წერტილოვანი ფერის ნიმუშის დამატება", + "Add Red Eye Correction": "წითელი თვალების კორექციის დამატება", + "Add Remove Spot": "მოშორების ლაქის დამატება", + "Add Selected Photos": "მონიშნული ფოტოების დამატება", + "Add a Face Recognition Model": "სახის ამოცნობის მოდელის დამატება", + "Add a rule (⌥-click: a group)": "წესის დამატება (⌥-დაწკაპუნება: ჯგუფი)", + "Add in place": "ადგილზე დამატება", + "Add in place: the library references the files where they are; nothing is copied or moved.": "ადგილზე დამატება: ბიბლიოთეკა ფაილებს მათ ამჟამინდელ ადგილას მიმართავს; არაფერი კოპირდება ან გადაიტანება.", + "Add keyword": "საკვანძო სიტყვის დამატება", + "Add number": "რიცხვის დამატება", + "Add to": "დამატება:", + "Add to Album": "ალბომში დამატება", + "Add to All Selected Photos": "ყველა მონიშნულ ფოტოზე დამატება", + "Add to Apple Photos": "Apple Photos-ში დამატება", + "Add to Apple Photos (macOS)": "Apple Photos-ში დამატება (macOS)", + "Add to Favorites": "რჩეულებში დამატება", + "Add to Local": "ლოკალურში დამატება", + "Add to My Photos": "ჩემს ფოტოებში დამატება", + "Add to My Photos First": "ჯერ ჩემს ფოტოებში დამატება", + "Add to Selected Photos": "მონიშნულ ფოტოებზე დამატება", + "Add to Target Album": "სამიზნე ალბომში დამატება", + "Add to favorites": "რჩეულებში დამატება", + "Add to this mask": "ამ ნიღაბზე დამატება", + "Add/Subtract/Intersect Mask": "ნიღბის დამატება/გამოკლება/გადაკვეთა", + "Added to photos you import that don't already have it.": "ემატება იმპორტირებულ ფოტოებს, რომლებსაც ის ჯერ არ აქვთ.", + "Adding": "დამატება", + "Adding Lightroom photos…": "Lightroom-ის ფოტოების დამატება…", + "Adding photos…": "ფოტოების დამატება…", + "Adding to Apple Photos": "Apple Photos-ში დამატება", + "Adding to Apple Photos…": "Apple Photos-ში დამატება…", + "Advanced search…": "გაფართოებული ძიება…", + "After": "შემდეგ", + "After 30 days": "30 დღის შემდეგ", + "After 7 days": "7 დღის შემდეგ", + "After 90 days": "90 დღის შემდეგ", + "After a year": "ერთი წლის შემდეგ", + "After installation, AI Denoise will turn on for the requesting photo.": "დაინსტალირების შემდეგ AI ხმაურის მოცილება მომთხოვნ ფოტოზე ჩაირთვება.", + "Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`.": "აგენტები ამოცანებს ჩამოთვლიან და აჩერებენ `activity.list`-ით და `activity.cancel`-ით; `ui.inspect` მათ `activity`-ად აბრუნებს.", + "Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`.": "აგენტები იყენებენ `app.newLibrary {path, name}`, `app.recentLibraries` და `app.clearRecentLibraries`.", + "Agents: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` for files already on disk, and": "აგენტები: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` დისკზე უკვე არსებული ფაილებისთვის, და", + "Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.": "აგენტები: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` იღებს `sidebar`-ს.", + "Album": "ალბომი", + "Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.": "ალბომების საქაღალდეები იკეცება იმავე სამკუთხედით, როგორც ლოკალური, თარიღით, საქაღალდეები და საკვანძო სიტყვები.", + "Album rule is \"is\" or \"isn't\" an album, picked with the album picker.": "ალბომის წესი არის „არის“ ან „არ არის“ ალბომი, რომელიც ალბომის ამრჩევით აირჩევა.", + "Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for": "გადარქმევის, ახალი სმარტ-ალბომის, საკვანძო სიტყვის გადარქმევის, საკვანძო სიტყვების გაერთიანების და მსგავსი დიალოგების სახელის ველზე, ან უსახელო სახეების ქვეშ სახელის ველზე", + "Albums": "ალბომები", + "Albums hold library photos: Add to My Photos first": "ალბომებში ბიბლიოთეკის ფოტოებია: ჯერ დაამატეთ ჩემს ფოტოებში", + "Albums tree": "ალბომების ხე", + "All": "ყველა", + "All Metadata": "ყველა მეტამონაცემი", + "All Metadata…": "ყველა მეტამონაცემი…", + "All Photos": "ყველა ფოტო", + "All rights reserved": "ყველა უფლება დაცულია", + "All the photos imported from this disk are included.": "ამ დისკიდან იმპორტირებული ყველა ფოტო შედის.", + "All types": "ყველა ტიპი", + "Already installed. Remove it before installing a replacement.": "უკვე დაინსტალირებულია. ჩანაცვლების დაინსტალირებამდე წაშალეთ.", + "Alt Text": "ალტერნატიული ტექსტი", + "Always": "ყოველთვის", + "Amount": "სიძლიერე", + "An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports": "შეუკუმშავი Leaf Aptus 22 MOS აღარ იხსნება მისი სენსორის მოზაიკის მუქ, უფერო სურათად: ის იმპორტდება", + "Analyzing the photo for AI masks…": "ფოტოს ანალიზი AI ნიღბებისთვის…", + "Angle": "კუთხე", + "Any Searchable Text": "ნებისმიერი საძიებო ტექსტი", + "Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.": "ნებისმიერი საძიებო ტექსტი ასევე პოულობს შტატს / პროვინციას, ალტერნატიულ ტექსტს, ადამიანებსა და ვირტუალური ასლის სახელს.", + "Any camera": "ნებისმიერი კამერა", + "Any keyword": "ნებისმიერი საკვანძო სიტყვა", + "Any lens": "ნებისმიერი ობიექტივი", + "Aperture": "დიაფრაგმა", + "Appearance": "იერსახე", + "Appearance Mode": "იერსახის რეჟიმი", + "Appearance Settings…": "იერსახის პარამეტრები…", + "Appearance lists the modes and themes. On Linux, Auto follows the desktop portal's colour scheme": "იერსახე კი რეჟიმებსა და თემებს ჩამოთვლის. Linux-ზე ავტომატური მიჰყვება დესკტოპის პორტალის ფერთა სქემას", + "Apple Photos Imports": "Apple Photos-ში დამატებები", + "Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is": "Apple ProRAW-ის მოგების ცხრილის რუკა (მისი ლოკალური ტონალური ასახვა, `ProfileGainTableMap`) ახლა იკითხება და ინახება, როცა ფოტო", + "Application": "აპლიკაცია", + "Apply": "გამოყენება", + "Apply Color Label Set": "ფერადი იარლიყების ნაკრების გამოყენება", + "Apply Filter Preset": "ფილტრის პრესეტის გამოყენება", + "Apply Metadata Preset": "მეტამონაცემების პრესეტის გამოყენება", + "Apply Point Curve Preset": "წერტილოვანი მრუდის პრესეტის გამოყენება", + "Apply Preset": "პრესეტის გამოყენება", + "Apply Settings JSON": "JSON პარამეტრების გამოყენება", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "RAW-ში შენახული ლოკალური ტონალური ასახვის გამოყენება (Apple ProRAW), როგორც კამერა ასახავს", + "Approved": "დამტკიცებულია", + "Aqua": "ზღვისფერი", + "Aqua Hue": "ზღვისფერის ფერის ტონი", + "Aqua Luminance": "ზღვისფერის სიკაშკაშე", + "Aqua Saturation": "ზღვისფერის გაჯერება", + "Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text": "არაბული სახელები და სხვა არაბული ტექსტი სათაურებში, არჩეულ ჩანართებსა და სხვა მუქ ადგილებში ახლა მუქად იხატება, როგორც მათ გვერდით", + "Arrange Panel": "პანელის დალაგება", + "Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line": "დაალაგეთ მარცხენა გვერდითი ზოლი თქვენებურად: გადაათრიეთ ალბომები, ლოკალური, თარიღის მიხედვით, საქაღალდეები ან საკვანძო სიტყვები სათაურით (ხაზი", + "ArtCraft Website": "ArtCraft-ის ვებსაიტი", + "ArtCraft — more creative apps": "ArtCraft — სხვა შემოქმედებითი აპლიკაციები", + "As Shot": "როგორც გადაღებულია", + "As shot": "როგორც გადაღებულია", + "Ascending": "ზრდადობით", + "Aspect": "თანაფარდობა", + "Aspect Ratio": "თანაფარდობა", + "Aspect Ratio (a 24 MP photo cropped to a square is 16 MP).": "თანაფარდობა (24 MP ფოტო, კვადრატად ჩამოჭრილი, 16 MP-ია).", + "Assisted Culling": "დამხმარე გადარჩევა", + "Assisted Culling…": "დამხმარე გადარჩევა…", + "Auto": "ავტომატური", + "Auto Advance": "ავტომატური გადასვლა", + "Auto Advance Off": "ავტომატური გადასვლის გამორთვა", + "Auto Advance On": "ავტომატური გადასვლის ჩართვა", + "Auto Advance after rating or flagging": "ავტომატური გადასვლა შეფასების ან დროშის დასმის შემდეგ", + "Auto Advance: move to the next photo after rating or flagging": "ავტომატური გადასვლა: შეფასების ან დროშის დასმის შემდეგ შემდეგ ფოტოზე გადასვლა", + "Auto Align": "ავტოსწორება", + "Auto B&W Mix": "ავტომატური შავ-თეთრი მიქსი", + "Auto Crop": "ავტომატური ჩამოჭრა", + "Auto Import": "ავტოიმპორტი", + "Auto Import Now": "ავტოიმპორტი ახლავე", + "Auto Import Settings": "ავტოიმპორტის პარამეტრები", + "Auto Mask": "ავტონიღაბი", + "Auto Select": "ავტომატური მონიშვნა", + "Auto Settings": "ავტომატური პარამეტრები", + "Auto Straighten": "ავტომატური გასწორება", + "Auto Sync": "ავტოსინქრონიზაცია", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "ავტოსინქრონიზაცია: ცვლილებები ყველა მონიშნულ ფოტოზე ვრცელდება. ავტომატური შავ-თეთრი მიქსი. ავტომატური ვერსიები.", + "Auto settings applied": "ავტომატური პარამეტრები გამოყენებულია", + "Auto use the same model. Custom white balances on these photos render slightly differently than before.": "ავტომატური იმავე მოდელს იყენებს. ამ ფოტოებზე თეთრის მორგებული ბალანსი ადრინდელისგან ოდნავ განსხვავებულად რენდერდება.", + "Auto-Stack by Capture Time": "ავტომატური დასტებად დაჯგუფება გადაღების დროით", + "Auto-Stack by Capture Time…": "ავტომატური დასტებად დაჯგუფება გადაღების დროით…", + "Auto-Tag Photos from Tracklog": "ფოტოებისთვის მდებარეობის მინიჭება მარშრუტის ჟურნალიდან", + "Auto-Tag from Tracklog": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან: GPS მდებარეობები თქვენი ფოტოებისთვის GPX მარშრუტის ჟურნალიდან, გადაღების დროის მიხედვით.", + "Auto-Tag from Tracklog…": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან…", + "Automatic": "ავტომატური", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "ავტომატური რეჟიმი დეტალურ ხედს ასახავს იმ ზომით, რომლითაც ის ჩანს, ფოტოს საკუთარ პიქსელებამდე. ზღვრისთვის აირჩიეთ ზომა: პატარა უფრო სწრაფია.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "ავტომატური რეჟიმი იყენებს ვიდეობარათს, როცა ის პროცესორზე სწრაფია: ორივეს დრო ამ კომპიუტერზე იზომება, როცა ფოტო პირველად მზადდება.", + "Automatically Write Changes into XMP": "ცვლილებების XMP-ში ავტომატურად ჩაწერა", + "Automatically write changes into XMP sidecars": "ცვლილებების XMP თანმხლებ ფაილებში ავტომატურად ჩაწერა", + "Autumn Gold": "შემოდგომის ოქრო", + "B&W": "შავ-თეთრი", + "B&W Mix": "შავ-თეთრი მიქსი", + "B&W Mixer": "შავ-თეთრის მიქშერი", + "Back": "უკან", + "Back Up Library…": "ბიბლიოთეკის სარეზერვო ასლი…", + "Back to Edit": "რედაქტირებაზე დაბრუნება", + "Back to Edit (E)": "რედაქტირებაზე დაბრუნება (E)", + "Back to Grid": "ბადეზე დაბრუნება", + "Back to this person": "ამ ადამიანთან დაბრუნება", + "Background": "ფონი", + "Background Tasks": "ფონური ამოცანები", + "Balance": "ბალანსი", + "Basic": "საბაზო", + "Before": "მანამდე", + "Before/After (Y)": "მანამდე/შემდეგ (Y)", + "Before/After Settings": "მანამდე/შემდეგ პარამეტრები", + "Before/After Split": "მანამდე/შემდეგ, გაყოფილი", + "Before/After Split Top/Bottom": "მანამდე/შემდეგ, გაყოფილი ზემოთ/ქვემოთ", + "Before/After Top/Bottom": "მანამდე/შემდეგ ზემოთ/ქვემოთ", + "Begin Interaction": "ურთიერთქმედების დაწყება", + "Best of Similar Shots": "მსგავსი კადრებიდან საუკეთესო", + "Binary assets added": "დამატებული ბინარული ფაილები", + "Binary assets removed": "წაშლილი ბინარული ფაილები", + "Bit depth": "ბიტური სიღრმე", + "Black & White": "შავ-თეთრი", + "Blacks": "შავები", + "Bleach Bypass": "გაუთეთრებელი დამუშავება", + "Bleached": "გაუფერულებული", + "Blending": "შერევა", + "Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),": "გადაჭარბებით განათებული ღია ტონები Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),", + "Blue": "ლურჯი", + "Blue Hour Boost": "ლურჯი საათის გაძლიერება", + "Blue Hue": "ლურჯის ფერის ტონი", + "Blue Luminance": "ლურჯის სიკაშკაშე", + "Blue Primary": "ლურჯი ძირითადი", + "Blue Saturation": "ლურჯის გაჯერება", + "Blue channel": "ლურჯი არხი", + "Blue label": "ლურჯი იარლიყი", + "Blue/Yellow Fringe": "ლურჯი/ყვითელი შარავანდი", + "Blurry photos below the score are flagged as rejects.": "ზღვარზე დაბალი შეფასების ბუნდოვან ფოტოებს უარყოფილის დროშა ედება.", + "Bold text in other scripts": "მუქი ტექსტი სხვა დამწერლობებში", + "Boundary Warp": "საზღვრების დეფორმაცია", + "Bridge and Photo Supreme (its Formatted Vocabulary File) use the same files, so keyword lists move between them and": "Bridge და Photo Supreme (მისი Formatted Vocabulary File) იმავე ფაილებს იყენებს, ასე რომ საკვანძო სიტყვების სიები მათსა და", + "Bright & Airy": "ნათელი და ჰაეროვანი", + "Bring All to Front": "ყველას წინა პლანზე გადატანა", + "Bring the library up to date with this folder and the folders inside it:": "ბიბლიოთეკის განახლება ამ საქაღალდისა და მასში არსებული საქაღალდეების მიხედვით:", + "Browse Folder": "საქაღალდის დათვალიერება", + "Browse Folder…": "საქაღალდის დათვალიერება…", + "Browse Profiles": "პროფილების დათვალიერება", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "ბრაუზერის ვერსია (ექსპერიმენტული), ბიბლიოთეკის დაცვა (ხარვეზი #107): ფაილი → ბიბლიოთეკის სარეზერვო ასლი… ჩამოტვირთავს კატალოგს", + "Browse…": "არჩევა…", + "Brush": "ფუნჯი", + "Brush Settings": "ფუნჯის პარამეტრები", + "Brush Stroke": "ფუნჯის მონასმი", + "Build Previews": "წინასწარი ხედების შექმნა", + "Build Smart Previews": "ჭკვიანი წინასწარი ხედების შექმნა", + "Building previews": "წინასწარი ხედების შექმნა", + "Building smart previews": "ჭკვიანი წინასწარი ხედების შექმნა", + "By Date": "თარიღით", + "By day (YYYY/YYYY-MM-DD)": "დღის მიხედვით (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "თვის მიხედვით (YYYY/YYYY-MM)", + "C-RAWs imported earlier, Photo ▸ Reload from Disk picks up the raw data.": "R7 C-RAW-ებისთვის ფოტო → დისკიდან ხელახლა ჩატვირთვა RAW მონაცემებს წაიკითხავს.", + "CPU threads": "CPU ნაკადები", + "Cache": "ქეში", + "Calibration": "კალიბრაცია", + "Camera": "კამერა", + "Camera Info": "კამერის ინფორმაცია", + "Camera local tone mapping": "კამერის ლოკალური ტონალური ასახვა", + "Camera make and model": "კამერის მწარმოებელი და მოდელი", + "Camera-look deep shadows": "ღრმა ჩრდილები კამერის იერში", + "Cameras and Cards": "კამერები და ბარათები", + "Cancel": "გაუქმება", + "Cancel AI Mask Model Download": "AI ნიღბების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Background Task": "ფონური ამოცანის გაუქმება", + "Cancel Denoise": "ხმაურის მოცილების გაუქმება", + "Cancel Denoise Model Download": "ხმაურის მოცილების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Download": "ჩამოტვირთვის გაუქმება", + "Cancel Face Model Download": "სახეების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Interaction": "ურთიერთქმედების გაუქმება", + "Cancel Pending Photo Action": "ფოტოს მოლოდინში მყოფი მოქმედების გაუქმება", + "Cancel Preview Build": "წინასწარი ხედების შექმნის გაუქმება", + "Cancel pending action": "მოლოდინში მყოფი მოქმედების გაუქმება", + "Candidate": "კანდიდატი", + "Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF": "Canon CR2 და Pentax PEF RAW-ები კამერის საკუთარ JPEG-ზე მორგებულ იმავე საწყის იერს იღებს, როგორც ARW, NEF, RW2, RAF", + "Canon CR2 highlights": "Canon CR2-ის ღია ტონები", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Canon CR2 ფოტოები EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II და 1D Mark IV კამერებიდან (და სხვა", + "Canon CR2 raws no longer open too dark with blocked-up shadows: the black level was measured over border columns": "Canon CR2 RAW-ები აღარ იხსნება ზედმეტად მუქად ჩაკეტილი ჩრდილებით: შავის დონე იზომებოდა კიდის სვეტებზე,", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Canon CR3 ფაილები აჩვენებს სრული ზომის ჩაშენებულ JPEG-ს (მაგ., 6960 × 4640 EOS R6 Mark III-ზე) ნაცვლად", + "Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS": "Canon CR3 RAW-ები ახლა სენსორის მონაცემებიდან მუშავდება: უდანაკარგო RAW და C-RAW, ნიმუშ-ნიმუშ შემოწმებული EOS", + "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS and Epson ERF files that LightCraft can't decode yet": "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS და Epson ERF ფაილები, რომელთა დეკოდირება LightCraft-ს ჯერ არ შეუძლია,", + "Canon EOS R5 Mark II": "Canon EOS R5 Mark II", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Canon EOS R5 Mark II C-RAW ფაილები, რომლებიც „მხოლოდ წინასწარი ნახვის“ რეჟიმში იხსნებოდა (ნახევარზე მეტი,", + "Canon EOS R7 C-RAW files develop from their sensor data too, instead of opening from their embedded JPEG. For R7": "Canon EOS R7-ის C-RAW ფაილებიც სენსორის მონაცემებიდან მუშავდება, ჩაშენებული JPEG-იდან გახსნის ნაცვლად. ადრე იმპორტირებული", + "Canon PowerShot G9 raws open with the camera's white balance (White Balance ▸ As Shot); it used to start from a": "Canon PowerShot G9-ის RAW ფაილები კამერის თეთრის ბალანსით იხსნება (თეთრის ბალანსი → როგორც გადაღებულია); ადრე ისინი", + "Caption": "წარწერა", + "Capture Date": "გადაღების თარიღი", + "Capture date and time in your own format (directives below)": "გადაღების თარიღი და დრო თქვენივე ფორმატით (დირექტივები ქვემოთ)", + "Capture date as YYYYMMDD": "გადაღების თარიღი YYYYMMDD ფორმატით", + "Captured": "გადაღებულია", + "Captured from": "გადაღებულია დან", + "Captured until": "გადაღებულია მდე", + "Catalog Statistics": "კატალოგის სტატისტიკა", + "Catchlight": "თვალის ანარეკლი", + "Change": "შეცვლა", + "Change Component Mode": "კომპონენტის რეჟიმის შეცვლა", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ფაილის სახელებში დაუშვებელი სიმბოლოები (/ \\ : * ? \" < > |) იცვლება -ით; არსებულ სახელს ემატება -1, -2….", + "Charcoal": "ნახშირი", + "Check All": "ყველას შემოწმება", + "Check Output Path": "გამოსატანი მისამართის შემოწმება", + "Check the files of this type": "ამ ტიპის ფაილების შემოწმება", + "Checking and installing the model…": "მოდელის შემოწმება და დაინსტალირება…", + "Checking names…": "სახელების შემოწმება…", + "Checking…": "შემოწმება…", + "Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.": "ჩინურის (გამარტივებული და ტრადიციული), იაპონურის და ბრაზილიური პორტუგალიურის გვერდით.", + "Choice values read as words in every language: \"Public Domain\", \"Picked\", \"Black & White\" rather than ids like": "არჩევანის მნიშვნელობები ყველა ენაზე სიტყვებად იკითხება: „საზოგადოებრივი საკუთრება“, „რჩეული“, „შავ-თეთრი“ და არა იდენტიფიკატორები, როგორიცაა", + "Choose Another Library…": "სხვა ბიბლიოთეკის არჩევა…", + "Choose Denoise Model": "ხმაურის მოცილების მოდელის არჩევა", + "Choose Edit Settings to Copy": "დასაკოპირებელი რედაქტირების პარამეტრების არჩევა", + "Choose Edit Settings to Copy…": "დასაკოპირებელი რედაქტირების პარამეტრების არჩევა…", + "Choose Face Recognition Model": "სახის ამოცნობის მოდელის არჩევა", + "Choose Folder…": "საქაღალდის არჩევა…", + "Choose Monitor Profile": "მონიტორის პროფილის არჩევა", + "Choose Profile…": "პროფილის არჩევა…", + "Choose a model below (Use) to start.": "დასაწყებად ქვემოთ აირჩიეთ მოდელი („გამოყენება“).", + "Choose a photo in the grid or filmstrip": "აირჩიეთ ფოტო ბადეში ან კადრების ზოლში", + "Choose an album…": "აირჩიეთ ალბომი…", + "Choose…": "არჩევა…", + "Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /": "Lightroom Classic-ის კატალოგის საქაღალდის არჩევა მენიუში ფაილი → ბიბლიოთეკის გახსნა… (ან მისი `Previews.lrdata` /", + "Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts": "თარიღის არჩევა „თარიღით“ განყოფილებაში ან საკვანძო სიტყვისა „საკვანძო სიტყვებში“ ამ ფოტოებს „ყველა ფოტოდან“ აჩვენებს, როგორც მათი რაოდენობა", + "Cinematic": "კინემატოგრაფიული", + "City": "ქალაქი", + "Clarity": "სიცხადე", + "Clean Studio": "სუფთა სტუდია", + "Clear": "გასუფთავება", + "Clear Cache": "ქეშის გასუფთავება", + "Clear Denoise Cache": "ხმაურის მოცილების ქეშის გასუფთავება", + "Clear Filters": "ფილტრების გასუფთავება", + "Clear Guides": "დამხმარე ხაზების გასუფთავება", + "Clear History": "ისტორიის გასუფთავება", + "Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება", + "Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება", + "Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება", + "Clear Reference": "ეტალონის გასუფთავება", + "Clear all": "ყველას გასუფთავება", + "Clear cache": "ქეშის გასუფთავება", + "Clear form": "ფორმის გასუფთავება", + "Clear rating": "შეფასების მოხსნა", + "Clear rating and advance": "შეფასების მოხსნა და გადასვლა", + "Cleared History": "ისტორია გასუფთავებულია", + "Click Zoom Ratio": "დაწკაპუნების მასშტაბი", + "Click a shortcut, then press the new keys (Esc cancels).": "დაწკაპეთ მალსახმობზე, შემდეგ დააჭირეთ ახალ კლავიშებს (Esc აუქმებს).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "დაწკაპეთ ჭდეზე მის ჩასასმელად. მაგალითები ეხება IMG_0042.CR3-ს, გადაღებულს 2026 წლის 14 იანვარს, 05:58:48-ზე.", + "Click photos in the grid to toggle the keyword; Esc stops": "დაწკაპეთ ფოტოებზე ბადეში საკვანძო სიტყვის ჩასართავად/გამოსართავად; Esc აჩერებს", + "Click the object to select it · ⌥-click leaves a part out": "დაწკაპეთ ობიექტზე მის მოსანიშნად · ⌥-დაწკაპუნება ნაწილს გამორიცხავს", + "Click the photo to pick a colour · ⇧-click adds more": "დაწკაპეთ ფოტოზე ფერის ასარჩევად · ⇧-დაწკაპუნება მეტს ამატებს", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "დაწკაპეთ ფოტოზე ფერის ასარჩევად; ⇧-დაწკაპუნება მეტს ამატებს (5-მდე)", + "Click to clear these filters": "დაწკაპეთ ამ ფილტრების გასასუფთავებლად", + "Click to restore · double-click to rename": "დაწკაპეთ აღსადგენად · ორჯერ დაწკაპეთ სახელის გადასარქმევად", + "Click to type a value": "დაწკაპეთ მნიშვნელობის შესაყვანად", + "Clone": "კლონირება", + "Close": "დახურვა", + "Close Panel": "პანელის დახურვა", + "Close Window": "ფანჯრის დახურვა", + "Close all": "ყველას დახურვა", + "Close file": "ფაილის დახურვა", + "Close panel": "პანელის დახურვა", + "Closing a library releases its lock explicitly (issue #680). On Linux and macOS the lock used to go with the file": "ბიბლიოთეკის დახურვა მის ბლოკს ცხადად ათავისუფლებს (ხარვეზი #680). Linux-სა და macOS-ზე ბლოკი ადრე ფაილის", + "Cloudy": "მოღრუბლული", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, ფოტოს ინარჩუნებს და მის ცვლილებებს აუქმებს.)", + "Collapse": "ჩაკეცვა", + "Collapse All Stacks": "ყველა დასტის ჩაკეცვა", + "Collapse Stack": "დასტის ჩაკეცვა", + "Color": "ფერი", + "Color Grading": "ფერის გრადაცია", + "Color Label": "ფერადი იარლიყი", + "Color Label Names": "ფერადი იარლიყების სახელები", + "Color Label Sets": "ფერადი იარლიყების ნაკრებები", + "Color Mixer": "ფერების მიქშერი", + "Color Noise Reduction": "ფერის ხმაურის შემცირება", + "Color Priority": "ფერის პრიორიტეტი", + "Color Range": "ფერების დიაპაზონი", + "Color label cleared": "ფერადი იარლიყი მოხსნილია", + "Color space": "ფერთა სივრცე", + "Color space: sRGB (AVIF)": "ფერთა სივრცე: sRGB (AVIF)", + "Colors": "ფერები", + "Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and a translucent footer along the bottom of the photo in Photo Grid. Label confirmations use a matching pale colour.": "ფერადი იარლიყები კვადრატულ ბადესა და დეტალური ხედის კადრების ზოლში ესკიზების გარშემოს ღებავს, ფოტოების ბადეში კი ფოტოს ქვედა კიდეზე გამჭვირვალე ზოლს ქმნის. იარლიყის დადასტურებები შესაბამის ღია ფერს იყენებს.", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "ფერადი იარლიყების ფილტრი რამდენიმე იარლიყით ერთდროულად; ლოკალურში გაშლადი საქაღალდეების ხე.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "ფერების დიაპაზონის ნიღბები: ნიმუშისთვის დაწკაპეთ ფოტოზე. სიკაშკაშის დიაპაზონები: დიაპაზონის ზოლი, სიგლუვე, სიკაშკაშის რუკა.", + "Combine files…": "ფაილების გაერთიანება…", + "Command Journal": "ბრძანებების ჟურნალი", + "Comments panel": "კომენტარების პანელი", + "Commits": "კომიტები", + "Company": "კომპანია", + "Compare": "შედარება", + "Compare (Shift+C)": "შედარება (Shift+C)", + "Compare Before and After": "მანამდე და შემდეგ შედარება", + "Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ", + "Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება", + "Component options": "კომპონენტის პარამეტრები", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "შეკუმშული Nikon NEF-ები (უდანაკარგოდ და დანაკარგით შეკუმშული, 12- და 14-ბიტიანი — მაგ., D3200, D5100, D7000, D750, D850, Z 50)", + "Compression": "შეკუმშვა", + "Concrete Cool": "ცივი ბეტონი", + "Confirm before deleting": "წაშლამდე დადასტურება", + "Confirm before moving photos to Recently Deleted": "ფოტოების ახლახან წაშლილში გადატანამდე დადასტურება", + "Constrain Crop": "ჩამოჭრის შეზღუდვა", + "Contact Sheet PDF…": "საკონტაქტო ფურცელი PDF…", + "Content-Aware": "შიგთავსის გათვალისწინებით", + "Continue": "გაგრძელება", + "Continue Without Saving": "გაგრძელება შენახვის გარეშე", + "Contrast": "კონტრასტი", + "Contributors": "მონაწილეები", + "Convert to DNG": "DNG-ად გარდაქმნა", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "DNG-ად გარდაქმნა, DNG-ად კოპირება, ფოტოების გაერთიანება და ჭკვიანი წინასწარი ხედები აღარ წერს პირდაპირ საბოლოო ფაილში (ხარვეზი", + "Cool Fade": "ცივი მიქრობა", + "Cool Morning": "ცივი დილა", + "Copy": "კოპირება", + "Copy After's Settings to Before": "„შემდეგ“-ის პარამეტრების „მანამდე“-ში კოპირება", + "Copy Before to After": "„მანამდე“-ს „შემდეგ“-ში კოპირება", + "Copy Before's Settings to After": "„მანამდე“-ს პარამეტრების „შემდეგ“-ში კოპირება", + "Copy Edit Settings": "რედაქტირების პარამეტრების კოპირება", + "Copy History Step Settings to Before": "ისტორიის ნაბიჯის პარამეტრების „მანამდე“-ში კოპირება", + "Copy Metadata": "მეტამონაცემების კოპირება", + "Copy Name": "ასლის სახელი", + "Copy as DNG": "DNG-ად კოპირება", + "Copy into the library (else use the files where they are)": "ბიბლიოთეკაში კოპირება (თორემ ფაილები მათ ადგილას გამოიყენება)", + "Copy pages": "გვერდების კოპირება", + "Copy the files (into the library's Originals/, or a folder you choose)": "ფაილების კოპირება (ბიბლიოთეკის Originals/-ში ან თქვენ მიერ არჩეულ საქაღალდეში)", + "Copy to": "კოპირება:", + "Copy to Clipboard": "გაცვლის ბუფერში კოპირება", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "კოპირება: ფაილები კოპირდება ქვემოთ მითითებულ საქაღალდეში და ბიბლიოთეკა ასლებს იყენებს; ორიგინალები უცვლელი რჩება.", + "Copyright": "საავტორო უფლება", + "Copyright Info URL": "საავტორო უფლებების ინფორმაციის URL", + "Copyright Status": "საავტორო უფლებების სტატუსი", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "საავტორო უფლებების სტატუსი, უფლებების გამოყენების პირობები და საავტორო უფლებების ინფორმაციის URL ინფორმაციაში, მეტამონაცემების პრესეტებსა და ექსპორტში.", + "Copyrighted": "დაცულია საავტორო უფლებით", + "Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the": "კორექციების ჩართვის ქვეშ, ახლად იმპორტირებული ფოტოებისთვის ნაგულისხმევად ჩართული, კამერის JPEG-ის იმავე კადრირებით. 12 მმ-ზე", + "Country": "ქვეყანა", + "Create": "შექმნა", + "Create Album": "ალბომის შექმნა", + "Create Album…": "ალბომის შექმნა…", + "Create Folder": "საქაღალდის შექმნა", + "Create Folder…": "საქაღალდის შექმნა…", + "Create Keyword": "საკვანძო სიტყვის შექმნა", + "Create Keyword Tag": "საკვანძო სიტყვის შექმნა", + "Create New Mask": "ახალი ნიღბის შექმნა", + "Create PDF from clipboard": "PDF-ის შექმნა გაცვლის ბუფერიდან", + "Create PDF from file…": "PDF-ის შექმნა ფაილიდან…", + "Create PDF from images…": "PDF-ის შექმნა გამოსახულებებიდან…", + "Create Preset": "პრესეტის შექმნა", + "Create Preset…": "პრესეტის შექმნა…", + "Create Proof Copy": "საცდელი ასლის შექმნა", + "Create Smart Album": "სმარტ-ალბომის შექმნა", + "Create Smart Album from Filter…": "სმარტ-ალბომის შექმნა ფილტრიდან…", + "Create Smart Album…": "სმარტ-ალბომის შექმნა…", + "Create Stack": "დასტის შექმნა", + "Create Version": "ვერსიის შექმნა", + "Create Version from Step": "ვერსიის შექმნა ნაბიჯიდან", + "Create Virtual Copy": "ვირტუალური ასლის შექმნა", + "Create keywords with + (inside the one you picked, with synonyms, and given to the selection if you like), edit one": "საკვანძო სიტყვების შექმნა +-ით (არჩეულის შიგნით, სინონიმებით, და სურვილისამებრ მონიშნულისთვის მინიჭებით), რედაქტირება", + "Creator": "ავტორი", + "Creator metadata": "ავტორის მეტამონაცემები", + "Crisp Landscape": "მკვეთრი პეიზაჟი", + "Crisp Vista": "მკვეთრი ხედი", + "Crop": "ჩამოჭრა", + "Crop & Rotate": "ჩამოჭრა და შემობრუნება", + "Crop & Rotate (C)": "ჩამოჭრა და შემობრუნება (C)", + "Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and": "ჩამოჭრა (ხარვეზი #295): ჩაკეტვის გადამრთველი თანაფარდობას ყველა სახელურზე ინარჩუნებს, მორგებული თქვენს თანაფარდობას იღებს (გამოყენება), და", + "Crop Aspect": "ჩამოჭრის თანაფარდობა", + "Crop rotation": "ჩამოჭრის შემობრუნება", + "Crop, Rotate, Geometry": "ჩამოჭრა, შემობრუნება, გეომეტრია", + "Cropped": "ჩამოჭრილი", + "Cross Process": "ჯვარედინი დამუშავება", + "Culling": "გადარჩევა", + "Culling.": "გადარჩევა.", + "Curve": "მრუდი", + "Custom": "მორგებული", + "Custom template…": "მორგებული შაბლონი…", + "Custom…": "მომხმარებლის…", + "Cut": "ამოჭრა", + "Cut pages": "გვერდების ამოჭრა", + "Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in the Info panel it": "ხელმისაწვდომია ამოჭრა, კოპირება, ჩასმა და ყველაფრის მონიშვნა. Esc მათში აკრეფის დაწყებამდე არსებულ ტექსტს აბრუნებს (ინფორმაციის პანელში", + "Cycle Crop Overlay": "ჩამოჭრის ზედდების მონაცვლეობა", + "Cycle Crop Overlay Orientation": "ჩამოჭრის ზედდების ორიენტაციის მონაცვლეობა", + "Cycle Info Overlay": "ინფორმაციის ზედდების მონაცვლეობა", + "Cycle Mask Overlay Color": "ნიღბის ზედდების ფერის მონაცვლეობა", + "Cycle Mask Overlay Mode": "ნიღბის ზედდების რეჟიმის მონაცვლეობა", + "Cylindrical": "ცილინდრული", + "Dark & Moody": "მუქი და პირქუში", + "Dark Mode": "ბნელი რეჟიმი", + "Dark Theme": "ბნელი თემა", + "Darken": "ჩამუქება", + "Darks": "მუქი ტონები", + "Date": "თარიღი", + "Date Groups": "თარიღის ჯგუფები", + "Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the": "თარიღის წესებს აქვს კალენდრის ღილაკი: აირჩიეთ დღე, თვე ან მთელი წელი (წელი / თვე / დღე) თქვენს ენაზე;", + "Day": "დღე", + "Daylight": "დღის სინათლე", + "Daylight, Cloudy, Shade, Tungsten, Fluorescent and Flash presets set real temperatures (issue #730). The matrices": "პრესეტები დღის სინათლე, მოღრუბლული, ჩრდილი, ვარვარების ნათურა, ფლუორესცენტური და ფლეში ნამდვილ ტემპერატურას აყენებს (ხარვეზი #730). მატრიცები", + "Decrease Brush Feather": "ფუნჯის კიდეების შერბილების შემცირება", + "Decrease Brush Size": "ფუნჯის ზომის შემცირება", + "Decrease Rating": "შეფასების შემცირება", + "Deep Sky": "ღრმა ცა", + "Defringe": "კიდის შარავანდის მოცილება", + "Deghost Amount": "აჩრდილების მოცილების სიძლიერე", + "Dehaze": "ბურუსის მოცილება", + "Delete": "წაშლა", + "Delete (⌫)": "წაშლა (⌫)", + "Delete Album": "ალბომის წაშლა", + "Delete All Masks": "ყველა ნიღბის წაშლა", + "Delete Color Label Set": "ფერადი იარლიყების ნაკრების წაშლა", + "Delete Component": "კომპონენტის წაშლა", + "Delete Export Preset": "ექსპორტის პრესეტის წაშლა", + "Delete Filter Preset": "ფილტრის პრესეტის წაშლა", + "Delete Imported Profile": "იმპორტირებული პროფილის წაშლა", + "Delete Keyword": "საკვანძო სიტყვის წაშლა", + "Delete Keyword Set": "საკვანძო სიტყვების ნაკრების წაშლა", + "Delete Keyword…": "საკვანძო სიტყვის წაშლა…", + "Delete Mask": "ნიღბის წაშლა", + "Delete Metadata Preset": "მეტამონაცემების პრესეტის წაშლა", + "Delete Permanently": "სამუდამოდ წაშლა", + "Delete Photo": "ფოტოს წაშლა", + "Delete Photos": "ფოტოების წაშლა", + "Delete Point Color": "წერტილოვანი ფერის წაშლა", + "Delete Point Color Sample": "წერტილოვანი ფერის ნიმუშის წაშლა", + "Delete Point Curve Preset": "წერტილოვანი მრუდის პრესეტის წაშლა", + "Delete Preset": "პრესეტის წაშლა", + "Delete Red Eye": "წითელი თვალის წაშლა", + "Delete Red Eye Correction": "წითელი თვალების კორექციის წაშლა", + "Delete Spot": "ლაქის წაშლა", + "Delete Version": "ვერსიის წაშლა", + "Delete all": "ყველას წაშლა", + "Delete all spots": "ყველა ლაქის წაშლა", + "Delete mask": "ნიღბის წაშლა", + "Delete them": "მათი წაშლა", + "Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing": "წაშლილში, მეტამონაცემების განახლებების წაკითხვა. გამორთულ დისკზე არსებული საქაღალდის შესახებ ამას იტყობინება. სინქრონიზაცია", + "Denoise Model Downloads": "ხმაურის მოცილების მოდელების ჩამოტვირთვა", + "Denoise Models": "ხმაურის მოცილების მოდელები", + "Denoise Photos": "ფოტოებიდან ხმაურის მოცილება", + "Denoise Pump": "ხმაურის მოცილების რიგის გაშვება", + "Denoise Settings": "ხმაურის მოცილების პარამეტრები", + "Denoise Status": "ხმაურის მოცილების მდგომარეობა", + "Denoise all photos with an Amount": "ხმაურის მოცილება ყველა ფოტოზე, რომელსაც სიძლიერე აქვს", + "Density": "სიმკვრივე", + "Depth Range": "სიღრმის დიაპაზონი", + "Descending": "კლებადობით", + "Describe": "აღწერა", + "Describe what to keep": "აღწერეთ, რა დარჩეს", + "Describe what to leave out": "აღწერეთ, რა გამოირიცხოს", + "Describe what to select": "აღწერეთ, რა მოინიშნოს", + "Describe…": "აღწერა…", + "Description": "აღწერა", + "Deselect": "მონიშვნის მოხსნა", + "Deselect All": "ყველა მონიშვნის მოხსნა", + "Desert Heat": "უდაბნოს პაპანაქება", + "Desert Warmth": "უდაბნოს სითბო", + "Desktop": "სამუშაო მაგიდა", + "Detail": "დეტალი", + "Detail (D)": "დეტალური ხედი (D)", + "Detail has a per-photo AI Denoise switch and Amount. Bayer RAW photos use a disposable cache, with matching previews and exports and pure-Rust CPU/GPU inference.": "დეტალს აქვს ფოტოს დონის AI ხმაურის მოცილების გადამრთველი და სიძლიერე. Bayer RAW ფოტოები ერთჯერად ქეშს იყენებს, შესაბამისი წინასწარი ხედებით, ექსპორტით და სუფთა Rust-ზე CPU/GPU ინფერენსით.", + "Details": "დეტალები", + "Detect Faces": "სახეების ამოცნობა", + "Detect Faces (Photo menu) needs it: a small one-time download.": "სახეების ამოცნობას (მენიუ „ფოტო“) ეს სჭირდება: მცირე, ერთჯერადი ჩამოტვირთვა.", + "Develop": "დამუშავება", + "Diagonal": "დიაგონალური", + "Dialogs": "დიალოგები", + "Dialogs fit the window": "დიალოგები ფანჯარაში ეტევა", + "Dialogs that fit look as before.": "დიალოგები, რომლებიც ეტევა, ისევე გამოიყურება, როგორც ადრე.", + "Discard Preview Cache": "წინასწარი ხედების ქეშის წაშლა", + "Discarding smart previews": "ჭკვიანი წინასწარი ხედები იშლება", + "Display": "ეკრანი", + "Display P3": "Display P3", + "Display Profile": "ეკრანის პროფილი", + "Display name": "საჩვენებელი სახელი", + "Display profile not used": "ეკრანის პროფილი არ გამოიყენება", + "Display theme": "ინტერფეისის თემა", + "Distortion": "დეფორმაცია", + "Dock can be attached to a bug report. `LIGHTCRAFT_LOG` works as before; `RUST_LOG` takes env_logger-style": "შეცდომის ანგარიშს შეიძლება დაერთოს. `LIGHTCRAFT_LOG` ისე მუშაობს, როგორც ადრე; `RUST_LOG` იღებს env_logger-ის სტილის", + "Document properties…": "დოკუმენტის თვისებები…", + "Don't enlarge": "გადიდების გარეშე", + "Done": "მზადაა", + "Download": "ჩამოტვირთვა", + "Download AI Mask Model": "AI ნიღბების მოდელის ჩამოტვირთვა", + "Download Denoise Model": "ხმაურის მოცილების მოდელის ჩამოტვირთვა", + "Download Face Model": "სახეების მოდელის ჩამოტვირთვა", + "Download Place Names (GeoNames)": "ადგილების სახელების ჩამოტვირთვა (GeoNames)", + "Download the SAM 3 Model?": "ჩამოიტვირთოს SAM 3 მოდელი?", + "Downloaded and checked. Installing…": "ჩამოტვირთულია და შემოწმებულია. ინსტალირდება…", + "Downloading model": "მოდელი იტვირთება", + "Downloading place names": "ადგილების სახელები იტვირთება", + "Downloads": "ჩამოტვირთვები", + "Drag Crop Handle": "ჩამოჭრის სახელურის გადათრევა", + "Drag a keyword onto another to nest it, or onto the list's title to bring it back to the top level; photos dragged": "გადაათრიეთ საკვანძო სიტყვა მეორეზე მის შიგნით მოსათავსებლად, ან სიის სათაურზე ზედა დონეზე დასაბრუნებლად; ბადიდან", + "Drag along the horizon; double-click for Auto": "გაატარეთ ჰორიზონტის გასწვრივ; ორმაგი დაწკაპუნება — ავტომატურად", + "Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a": "გადაათრიეთ ალბომი ან საქაღალდე საქაღალდეზე მის იქ გადასატანად (დახურული საქაღალდე იხსნება, თუ მაჩვენებელი მასზე 0.6 წმ გაჩერდება;", + "Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated": "მონიშნული რადიალური გრადიენტის ელიფსის შიგნით ნებისმიერ ადგილას გადათრევით ის გადაადგილდება, მათ შორის შემობრუნებული", + "Drag over an eye on the photo; the pupil inside is found automatically.": "გაატარეთ ფოტოზე თვალის თავზე; შიგნით გუგა ავტომატურად მოიძებნება.", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "გადაათრიეთ ფერის ტონისა და გაჯერების დასაყენებლად. დააჭირეთ Shift-ს ფერის ტონის შესანარჩუნებლად, Cmd/Ctrl-ს — გაჯერების შესანარჩუნებლად, Alt/Option-ს — ზუსტი რეგულირებისთვის. ორჯერ დაწკაპეთ ჩამოსაყრელად.", + "Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you": "სლაიდერის გადათრევისას რედაქტირებული ფოტოს ესკიზი კადრების ზოლში აღარ რენდერდება ყოველ კადრზე; ის განახლდება, როცა", + "Draw Guides": "დამხმარე ხაზების დახატვა", + "Drop here for the top level": "ჩააგდეთ აქ ზედა დონისთვის", + "Duplicate": "დუბლირება", + "Duplicate Component": "კომპონენტის დუბლირება", + "Duplicate Mask": "ნიღბის დუბლირება", + "Duplicate and Invert Mask": "ნიღბის დუბლირება და ინვერსია", + "E-1, E-400 and XZ-2 corpus files: all accepted), with white balance relative to the as-shot look as for the other": "E-1, E-400 და XZ-2 კორპუსის ფაილები: ყველა მიღებულია), თეთრის ბალანსით გადაღებისას იერთან მიმართებით, როგორც სხვა", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "ყოველი / საქაღალდის ახალ დონეს იწყებს; ტეგები თითოეული ფოტოსთვის ივსება (დონე, რომელსაც მეტამონაცემები აკლია, არის \"unknown\").", + "Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until": "ადრე ყოველი ასეთი ხარვეზი რენდერის ერთ ნაკადს თან იყოლიებდა, ამიტომ რამდენიმე მათგანის შემდეგ არცერთი ფოტო არ რენდერდებოდა,", + "Edge": "კიდე", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "კიდის პარამეტრი და ფონურ რეჟიმში უფრო მკვეთრ, გადიდებულ დამუშავებას იღებენ. მოდელი LightCraft-ის შიგნით, სუფთა Rust-ზე მუშაობს", + "Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white).": "გვერდი, მოკლე გვერდი, თანაფარდობა (ჰორიზონტალური / ვერტიკალური / კვადრატული), ჩამოჭრილი და დამუშავება (ფერადი / შავ-თეთრი).", + "Edit": "რედაქტირება", + "Edit (E)": "რედაქტირება (E)", + "Edit Capture Time": "გადაღების დროის რედაქტირება", + "Edit Capture Time…": "გადაღების დროის რედაქტირება…", + "Edit Color Label Names": "ფერადი იარლიყების სახელების რედაქტირება", + "Edit Color Label Names…": "ფერადი იარლიყების სახელების რედაქტირება…", + "Edit Copy for External Editor": "ასლის რედაქტირება გარე რედაქტორში", + "Edit Date": "რედაქტირების თარიღი", + "Edit In": "რედაქტირება სხვა პროგრამაში", + "Edit Info": "ინფორმაციის რედაქტირება", + "Edit Keyword": "საკვანძო სიტყვის რედაქტირება", + "Edit Keyword Set": "საკვანძო სიტყვების ნაკრების რედაქტირება", + "Edit Keyword Tag": "საკვანძო სიტყვის რედაქტირება", + "Edit Keyword Tag…": "საკვანძო სიტყვის რედაქტირება…", + "Edit Label Names": "იარლიყების სახელების რედაქტირება", + "Edit Label Names…": "იარლიყების სახელების რედაქტირება…", + "Edit Mask": "ნიღბის რედაქტირება", + "Edit Mask Component": "ნიღბის კომპონენტის რედაქტირება", + "Edit Sections": "რედაქტირების სექციები", + "Edit Set…": "ნაკრების რედაქტირება…", + "Edit Smart Album": "სმარტ-ალბომის რედაქტირება", + "Edit Smart Album…": "სმარტ-ალბომის რედაქტირება…", + "Edit Spot": "ლაქის რედაქტირება", + "Edit in External Editor": "რედაქტირება გარე რედაქტორში", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "რედაქტირება გარე რედაქტორში (⇧⌘E): 16-ბიტიანი TIFF ასლი, დასტაში, განახლდება, როცა დაბრუნდებით.", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "რედაქტირება HDR-ში: SDR თეთრზე ღია ტონები, HDR ექსპორტისა და ეკრანებისთვის", + "Edit in Other Editor": "რედაქტირება სხვა რედაქტორში", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "„რედაქტირება სხვა რედაქტორში“ (⌥⌘E) ასლს ამ პროგრამაში ხსნის (macOS-ზე — აპის სახელი, სხვაგან — პროგრამის მისამართი).", + "Edit in PhotoCraft": "რედაქტირება PhotoCraft-ში", + "Edit settings copied": "რედაქტირების პარამეტრები დაკოპირდა", + "Edited": "შეცვლილია", + "Edited or not": "რედაქტირებული თუ არა", + "Editing": "რედაქტირება", + "Editing a smart album's name and rules is one step: both or neither, and one undo takes both back": "სმარტ-ალბომის სახელისა და წესების რედაქტირება ერთი ნაბიჯია: ან ორივე, ან არცერთი, და ერთი დაბრუნება ორივეს აბრუნებს", + "Effect": "ეფექტი", + "Effects": "ეფექტები", + "Empty Recently Deleted": "ახლახან წაშლილის გასუფთავება", + "Enable Lens Corrections": "ობიექტივის კორექციების ჩართვა", + "Enable Profile Corrections": "პროფილის კორექციების ჩართვა", + "End Interaction": "ურთიერთქმედების დასრულება", + "English": "English", + "Enter Full Screen": "სრულეკრანიან რეჟიმში გადასვლა", + "Enter a date as YYYY-MM-DD HH:MM:SS": "შეიყვანეთ თარიღი ფორმატით YYYY-MM-DD HH:MM:SS", + "Erase": "წაშლა", + "Evaluate Face Recognition": "სახეების ამოცნობის შეფასება", + "Every EXIF, GPS and XMP field in the file": "ფაილის ყველა EXIF, GPS და XMP ველი", + "Existing previews": "არსებული წინასწარი ხედები", + "Existing smart previews are refreshed when their originals are online, and cached renders are redrawn.": "არსებული ჭკვიანი წინასწარი ხედები ახლდება, როცა მათი ორიგინალები ხელმისაწვდომია, ხოლო ქეშირებული რენდერები ხელახლა იხატება.", + "Exit": "გასვლა", + "Expand": "გაშლა", + "Expand All Stacks": "ყველა დასტის ჩამოშლა", + "Expand Stack": "დასტის გაშლა", + "Expand/Collapse Stack": "დასტის გაშლა/ჩაკეცვა", + "Export": "ექსპორტი", + "Export Album with Preset": "ალბომის ექსპორტი პრესეტით", + "Export Album…": "ალბომის ექსპორტი…", + "Export As…": "ექსპორტი სხვა ფორმატით…", + "Export Contact Sheet PDF": "საკონტაქტო ფურცლის PDF-ად ექსპორტი", + "Export Containing Keywords": "მშობელი საკვანძო სიტყვების ექსპორტი", + "Export Group…": "ჯგუფის ექსპორტი…", + "Export Keywords": "საკვანძო სიტყვების ექსპორტი", + "Export Keywords…": "საკვანძო სიტყვების ექსპორტი…", + "Export Now": "ექსპორტი ახლავე", + "Export Point Curve Presets": "წერტილოვანი მრუდის პრესეტების ექსპორტი", + "Export Point Curve Presets…": "წერტილოვანი მრუდის პრესეტების ექსპორტი…", + "Export Presets": "ექსპორტის პრესეტები", + "Export Presets…": "პრესეტების ექსპორტი…", + "Export Synonyms": "სინონიმების ექსპორტი", + "Export User Presets…": "მომხმარებლის პრესეტების ექსპორტი…", + "Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: \"jxl\"` for agents and the CLI": "ექსპორტი JPEG XL-ად (ხარვეზი #247): ფორმატი → JPEG XL ექსპორტის დიალოგში, `format: \"jxl\"` აგენტებისა და CLI-სთვის", + "Export can write PSD too (flat, 8- or 16-bit).": "ექსპორტს PSD-ის ჩაწერაც შეუძლია (ერთშრიანი, 8 ან 16 ბიტი).", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "ექსპორტირებული ფაილების სახელები იყენებს ფოტოების გადარქმევის ტეგებს ({title}, {seq:2}, {date:%Y-%m-%d}…) და ახალ {num}, {folder}, {lens}, {iso}, {rating}, {creator}-ს.", + "Export stopped unexpectedly": "ექსპორტი მოულოდნელად შეწყდა", + "Export with Preset": "ექსპორტი პრესეტით", + "Export with Previous": "ექსპორტი წინა პარამეტრებით", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "ექსპორტირებული ფაილები ჯერ დროებით ფაილში იწერება და შემდეგ ადგილზე გადაერქმევა, ასე რომ სავსე დისკი ან გამორთული დისკი", + "Exporting": "ექსპორტირდება", + "Exporting contact sheet": "საკონტაქტო ფურცელი ექსპორტირდება", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "ექსპორტი და რენდერი ფოტოს ორიგინალს არასოდეს გადაწერს (ხარვეზი #93): ექსპორტი ფოტოს საკუთარ საქაღალდეში", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "ექსპორტი GPU-ის გამო აღარასოდეს არის შავი (ხარვეზი #78): GPU რენდერი, რომელსაც მოწყობილობის", + "Exports with noise reduction, Texture, Clarity, Dehaze or Highlights and Shadows can be much faster on the GPU,": "ექსპორტი ხმაურის შემცირებით, ტექსტურით, სიცხადით, ბურუსის მოცილებით ან ღია ტონებითა და ჩრდილებით GPU-ზე შეიძლება გაცილებით სწრაფი იყოს,", + "Export…": "ექსპორტი…", + "Exposure": "ექსპოზიცია", + "Exposure (shutter · aperture · ISO)": "ექსპოზიცია (ჩამკეტი · დიაფრაგმა · ISO)", + "Extended Description": "ვრცელი აღწერა", + "External Editing": "გარე რედაქტირება", + "External Editor": "გარე რედაქტორი", + "Face Boxes": "სახეების ჩარჩოები", + "Face Model Downloads": "სახეების მოდელების ჩამოტვირთვა", + "Face Models": "სახეების მოდელები", + "Face Recognition On/Off": "სახეების ამოცნობის ჩართვა/გამორთვა", + "Face boxes": "სახეების ჩარჩოები", + "Face detector": "სახეების დეტექტორი", + "Face models (ONNX)": "სახის მოდელები (ONNX)", + "Face names written to XMP by Lightroom and other apps show up here.": "სახეების სახელები, რომლებიც Lightroom-მა და სხვა აპებმა XMP-ში ჩაწერეს, აქ ჩანს.", + "Face recognition": "სახეების ამოცნობა", + "Faces": "სახეები", + "Faces of a Person": "ადამიანის სახეები", + "Faded Matte": "გაცრეცილი მატი", + "Faded Slide": "გაცრეცილი სლაიდი", + "Faded Urban": "გაცრეცილი ქალაქი", + "Failed": "ვერ მოხერხდა", + "Failed its last test": "ბოლო შემოწმება ვერ გაიარა", + "Faster GPU exports": "უფრო სწრაფი GPU ექსპორტი", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "უფრო სწრაფი ექსპორტი და ბარათიდან იმპორტი, იმავე დაცვით იმისთვის, რისი აღდგენაც შეუძლებელია (ხარვეზი #134): ექსპორტი,", + "Favorite Preset": "პრესეტის რჩეულებში დამატება", + "Favorite Profile": "პროფილის რჩეულებში დამატება", + "Favorites": "რჩეულები", + "Feather": "კიდეების შერბილება", + "File": "ფაილი", + "File & date": "ფაილი და თარიღი", + "File Extension": "ფაილის გაფართოება", + "File Format": "ფაილის ფორმატი", + "File Name": "ფაილის სახელი", + "File Path": "ფაილის მისამართი", + "File Size": "ფაილის ზომა", + "File Type": "ფაილის ტიპი", + "File extensions match whatever their letter case: `IMG_0001.JPG` and `DSC_0002.NEF` as Nikon writes them, `.Jpg`": "ფაილის გაფართოებები ემთხვევა ასოთა რეგისტრის მიუხედავად: `IMG_0001.JPG` და `DSC_0002.NEF`, როგორც Nikon წერს, `.Jpg`", + "File name": "ფაილის სახელი", + "File types:": "ფაილის ტიპები:", + "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "ფაილი → Lightroom-ის კატალოგის იმპორტი… პირდაპირ ხსნის `.lrcat`-ს, ორიგინალებზე მათ ადგილას მიმართვით.", + "File ▸ Import Keywords… and Export Keywords… read and write keyword list files as Lightroom Classic does (a keyword": "ფაილი → საკვანძო სიტყვების იმპორტი… და საკვანძო სიტყვების ექსპორტი… კითხულობს და წერს საკვანძო სიტყვების სიის ფაილებს, როგორც Lightroom Classic (თითო საკვანძო სიტყვა", + "File ▸ New Library… asks for a name, then where to put it, and opens the new library (issue #902).": "ფაილი → ახალი ბიბლიოთეკა… ითხოვს სახელს, შემდეგ მდებარეობას და ხსნის ახალ ბიბლიოთეკას (ხარვეზი #902).", + "File ▸ Open Recent Library lists the last 8 libraries; ones that are gone (moved, drive disconnected) are greyed": "ფაილი → ბოლოს გახსნილი ბიბლიოთეკის გახსნა ბოლო 8 ბიბლიოთეკას აჩვენებს; გამქრალი (გადატანილი, დისკი გამორთულია) ბიბლიოთეკები", + "Filename": "ფაილის სახელი", + "Files with the same content as another file in this import: only the first is imported.": "ფაილები, რომელთა შიგთავსი ამ იმპორტის სხვა ფაილს ემთხვევა: იმპორტდება მხოლოდ პირველი.", + "Fill": "შევსება", + "Fill Edges": "კიდეების შევსება", + "Fill Location": "მდებარეობის შევსება", + "Fill Location from GPS": "მდებარეობის შევსება GPS-ით", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "ცარიელი ქალაქის, შტატისა და ქვეყნის შევსება GPS-ით (GeoNames-ის ადგილების სახელები, ოფლაინ)", + "Film": "ფირი", + "Filmstrip": "კადრების ზოლი", + "Filmstrip (/)": "კადრების ზოლი (/)", + "Filter": "ფილტრი", + "Filter Bar": "ფილტრების ზოლი", + "Filter Keywords": "საკვანძო სიტყვების გაფილტვრა", + "Filter Presets": "ფილტრის პრესეტები", + "Filter bar": "ფილტრების ზოლი", + "Filter fields": "ფილტრის ველები", + "Find Blemishes": "ლაქების მოძებნა", + "Find Dust Spots": "მტვრის ლაქების მოძებნა", + "Find Missing Photos": "დაკარგული ფოტოების მოძებნა", + "Find Missing Photos…": "დაკარგული ფოტოების მოძებნა…", + "Find Similar Photos": "მსგავსი ფოტოების მოძებნა", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "ამ საქაღალდეში დისკზე დამატებული ან მისგან გამქრალი ფოტოების და სხვა აპების მიერ შეცვლილი XMP თანმხლები ფაილების მოძებნა", + "Find tools and commands…": "ხელსაწყოებისა და ბრძანებების ძებნა…", + "Finding faces": "სახეების ძებნა", + "Find…": "ძებნა…", + "First": "პირველი", + "First commit": "პირველი კომიტი", + "Fit": "ჩატევა", + "Fit (F)": "ჩატევა (F)", + "Fit on Screen": "ეკრანში ჩატევა", + "Fit to width": "სიგანეში ჩატევა", + "Fit visible": "ხილულში ჩატევა", + "Five stars": "ხუთი ვარსკვლავი", + "Fix the marked rules to save this album.": "ალბომის შესანახად გაასწორეთ მონიშნული წესები.", + "Fixed keys": "ფიქსირებული კლავიშები", + "Flag": "დროშა", + "Flag as Pick": "რჩეულად მონიშვნა", + "Flag as Reject": "უარყოფილად მონიშვნა", + "Flagged as Pick": "მონიშნულია რჩეულად", + "Flagged as Reject": "მონიშნულია უარყოფილად", + "Flash": "ფლეში", + "Flip H": "ჰორიზ. არეკვლა", + "Flip Horizontal": "ჰორიზონტალურად არეკვლა", + "Flip Overlay (⇧O)": "ზედდების არეკვლა (⇧O)", + "Flip V": "ვერტ. არეკვლა", + "Flip Vertical": "ვერტიკალურად არეკვლა", + "Float panel": "მცურავი პანელი", + "Flow": "ნაკადი", + "Fluorescent": "ფლუორესცენტური", + "Focal Length": "ფოკუსური მანძილი", + "Focal length": "ფოკუსური მანძილი", + "Focus (assisted culling)": "ფოკუსი (დამხმარე გადარჩევა)", + "Folder": "საქაღალდე", + "Folder colour labels": "საქაღალდის ფერადი იარლიყები", + "Folder name (renamed on disk; its photos follow)": "საქაღალდის სახელი (დისკზე გადაერქმევა; მისი ფოტოები თან მიჰყვება)", + "Folders": "საქაღალდეები", + "Folders and {date} use the capture time; a photo without one uses today's date.": "საქაღალდეები და {date} გადაღების დროს იყენებს; ფოტო მის გარეშე დღევანდელ თარიღს იყენებს.", + "Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose": "სინქრონიზაცია…. საქაღალდე და მისი ქვესაქაღალდეები ფონურად სკანირდება დისკზე დამატებული ფოტოების, ისეთი ფოტოების, რომელთა", + "Food": "საჭმელი", + "Forget Unchanged Local Photos": "უცვლელი ლოკალური ფოტოების დავიწყება", + "Forget unchanged photos": "უცვლელი ფოტოების დავიწყება", + "Form fields panel": "ფორმის ველების პანელი", + "Format": "ფორმატი", + "Formats": "ფორმატები", + "Forward": "წინ", + "Found automatically": "ავტომატურად მოიძებნა", + "Fr": "პარ", + "Frame time": "კადრის დრო", + "Frame update": "კადრის განახლება", + "Free": "თავისუფალი", + "Fresh & Bright": "სუფთა და კაშკაშა", + "Friday": "პარასკევი", + "Fujifilm RAFs from 59 camera models now get the lens correction the camera stores in the file: distortion (with the": "59 კამერის მოდელის Fujifilm RAF-ები ახლა იღებს ობიექტივის კორექციას, რომელსაც კამერა ფაილში ინახავს: დეფორმაცია (", + "Fujifilm lens corrections": "Fujifilm-ის ობიექტივის კორექციები", + "Full": "სრული", + "Full Screen Preview": "სრულეკრანიანი გადახედვა", + "Full screen mode": "სრულეკრანიანი რეჟიმი", + "Full size": "სრული ზომა", + "GPS Track Log": "GPS მარშრუტის ჟურნალი", + "GPU Rendering": "GPU რენდერირება", + "GPU rendering": "GPU რენდერირება", + "General": "ზოგადი", + "Generated demo photo": "გენერირებული სადემონსტრაციო ფოტო", + "Geometry": "გეომეტრია", + "Get Develop Settings": "დამუშავების პარამეტრების აღება", + "Global Hue": "გლობალური ფერის ტონი", + "Global Luminance": "გლობალური სიკაშკაშე", + "Global Saturation": "გლობალური გაჯერება", + "Glossy": "პრიალა", + "Go back to listing them by name": "სახელით ჩამონათვალზე დაბრუნება", + "Go to History Step": "ისტორიის ნაბიჯზე გადასვლა", + "Golden": "ოქროსფერი", + "Golden Glow": "ოქროსფერი ნათება", + "Golden Hour": "ოქროს საათი", + "Grab bag": "სხვადასხვა", + "Grain": "მარცვლოვნება", + "Graphic": "გრაფიკა", + "Graphics card": "ვიდეობარათი", + "Green": "მწვანე", + "Green Amount": "მწვანის სიძლიერე", + "Green Hue": "მწვანის ფერის ტონი", + "Green Hue High": "მწვანის ფერის ტონის ზედა ზღვარი", + "Green Hue Low": "მწვანის ფერის ტონის ქვედა ზღვარი", + "Green Luminance": "მწვანის სიკაშკაშე", + "Green Primary": "მწვანე ძირითადი", + "Green Saturation": "მწვანის გაჯერება", + "Green channel": "მწვანე არხი", + "Green label": "მწვანე იარლიყი", + "Grid": "ბადე", + "Grid Info": "ინფორმაცია ბადეში", + "Gritty Street": "უხეში ქუჩა", + "Group": "ჯგუფი", + "Group by Date: Automatic": "თარიღით დაჯგუფება: ავტომატური", + "Group by Day": "დღით დაჯგუფება", + "Group by Month": "თვით დაჯგუფება", + "Group by Year": "წლით დაჯგუფება", + "Group by date": "თარიღით დაჯგუფება", + "Group into Stack": "დასტაში გაერთიანება", + "Group name": "ჯგუფის სახელი", + "Guided": "მართვადი", + "Guided Upright": "მართვადი Upright", + "Guided Upright Guides": "მართვადი Upright-ის დამხმარე ხაზები", + "HDR": "HDR", + "HDR Headroom": "HDR მარაგი", + "HDR Merge": "HDR გაერთიანება", + "HDR Merge Preview": "HDR გაერთიანების გადახედვა", + "HDR PNG export": "HDR PNG ექსპორტი", + "HDR Panorama Merge": "HDR პანორამის გაერთიანება", + "HDR Panorama Merge Preview": "HDR პანორამის გაერთიანების გადახედვა", + "HDR Panorama with Last Settings": "HDR პანორამა ბოლო პარამეტრებით", + "HDR Panoramas": "HDR პანორამები", + "HDR Panorama…": "HDR პანორამა…", + "HDR and panorama merges of CR2 keep their colour": "CR2-ის HDR და პანორამული გაერთიანება ფერს ინარჩუნებს", + "HDR panorama": "HDR პანორამა", + "HDR panoramas": "HDR პანორამები", + "HDR with Last Settings": "HDR ბოლო პარამეტრებით", + "HDR…": "HDR…", + "HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind": "HEIC / HEIF ფოტოები (iPhone და Mac) ახლა იხსნება: არასავალდებულო `lightcraft-heif` crate (heic-rs, სუფთა Rust)", + "HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own": "HEIC-ის ფერები ახლა libheif-ს ერთი კოდური მნიშვნელობის სიზუსტით ემთხვევა (ადრე ~10-მდე სცდებოდა): HEVC ნაკადის საკუთარი", + "Has Edits": "აქვს ცვლილებები", + "Has GPS": "აქვს GPS", + "Heal": "აღდგენა", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "სენსორის მტვრის მიერ გლუვ არეებზე დატოვებული პატარა, რბილი მუქი ლაქების აღდგენა", + "Height": "სიმაღლე", + "Height (px)": "სიმაღლე (პქს)", + "Help": "დახმარება", + "Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).": "დახმარება → კლავიატურის მალსახმობები რიცხვითი კლავიშების მინიჭებებსაც შეიცავს. იხ. [ბიბლიოთეკის მალსახმობები](library-shortcuts.md).", + "Help → Open Log Folder shows that log file in the file manager (Finder, Explorer, or the folder on Linux), so it": "დახმარება → ჟურნალის საქაღალდის გახსნა ამ ჟურნალის ფაილს ფაილების მენეჯერში აჩვენებს (Finder, Explorer ან საქაღალდე Linux-ზე), ასე რომ ის", + "Hide LightCraft": "LightCraft-ის დამალვა", + "Hide Mask": "ნიღბის დამალვა", + "Hide Others": "სხვების დამალვა", + "Hide This Section": "ამ სექციის დამალვა", + "Hide the sections you don't use: View ▸ Sidebar Sections, or right-click a header ▸ Hide This Section. Right-click": "დამალეთ სექციები, რომლებსაც არ იყენებთ: ხედი → გვერდითი ზოლის სექციები, ან მარჯვენა დაწკაპუნება სათაურზე → ამ სექციის დამალვა. მარჯვენა დაწკაპუნებით", + "Hides this shortcut only; the folder and its photos stay as they are": "მხოლოდ ამ მალსახმობს მალავს; საქაღალდე და მისი ფოტოები ისე რჩება, როგორც არის", + "High": "მაღალი", + "High Contrast B&W": "მაღალკონტრასტული შავ-თეთრი", + "Highlight": "ღია ტონები", + "Highlights": "ღია ტონები", + "Highlights Hue": "ღია ტონების ფერის ტონი", + "Highlights Luminance": "ღია ტონების სიკაშკაშე", + "Highlights Saturation": "ღია ტონების გაჯერება", + "Highlights and Shadows (process 2)": "ღია ტონები და ჩრდილები (პროცესი 2)", + "Highlights and Shadows keep the fine detail of the tones they move (issue #632): pulling Highlights down on a bright": "ღია ტონები და ჩრდილები ინარჩუნებს იმ ტონების წვრილ დეტალებს, რომლებსაც ცვლის (ხარვეზი #632): ნათელ ცაზე ღია ტონების", + "Highlights split": "ღია ტონების გაყოფა", + "Histogram": "ჰისტოგრამა", + "History": "ისტორია", + "History & Activity (Y)": "ისტორია და აქტივობა (Y)", + "Home": "საშინაო საქაღალდე", + "Horizontal": "ჰორიზონტალური", + "Horizontal inset (% of short edge)": "ჰორიზონტალური შეწევა (მოკლე გვერდის %)", + "Horizontal text": "ჰორიზონტალური ტექსტი", + "Hours": "საათი", + "How to install the model": "როგორ დავაინსტალიროთ მოდელი", + "Hue": "ფერის ტონი", + "Hue Range": "ფერის ტონის დიაპაზონი", + "I have read these terms and accept them for my own use": "წავიკითხე ეს პირობები და ვეთანხმები მათ ჩემი საკუთარი გამოყენებისთვის", + "ICC Profiles": "ICC პროფილები", + "ISO Speed": "ISO მგრძნობიარობა", + "ISO speed": "ISO მგრძნობიარობა", + "If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has": "თუ LightCraft ვერ ხსნის ფოტოს (მისი ორიგინალი გამორთულ დისკზეა, ან ფაილი დაზიანებულია), მაგრამ აქვს", + "If file exists": "თუ ფაილი არსებობს", + "If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a": "თუ სამუშაო მაგიდის აპს ფანჯრის გახსნა არ შეუძლია (მაგალითად, როცა არცერთი გრაფიკული მოწყობილობის გამოყენება შეუძლებელია), ის ახლა ამას", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "თუ ბიბლიოთეკა გაშვებისას ვერ იხსნება (გახსნილია სხვა პროგრამაში, წაუკითხავია, მიუერთებელ დისკზეა,", + "Image": "გამოსახულება", + "Import": "იმპორტი", + "Import Date": "იმპორტის თარიღი", + "Import Keywords": "საკვანძო სიტყვების იმპორტი", + "Import Keywords…": "საკვანძო სიტყვების იმპორტი…", + "Import Lightroom Catalog": "Lightroom-ის კატალოგის იმპორტი", + "Import Lightroom Catalog…": "Lightroom-ის კატალოგის იმპორტი…", + "Import Photos": "ფოტოების იმპორტი", + "Import Photos…": "ფოტოების იმპორტი…", + "Import Point Curve Presets": "წერტილოვანი მრუდის პრესეტების იმპორტი", + "Import Point Curve Presets…": "წერტილოვანი მრუდის პრესეტების იმპორტი…", + "Import Presets": "პრესეტების იმპორტი", + "Import Presets…": "პრესეტების იმპორტი…", + "Import Profiles": "პროფილების იმპორტი", + "Import Profiles & Presets…": "პროფილებისა და პრესეტების იმპორტი…", + "Import as new": "იმპორტი ახლის სახით", + "Import from Device": "იმპორტი მოწყობილობიდან", + "Import from Folder…": "იმპორტი საქაღალდიდან…", + "Import new photos": "ახალი ფოტოების იმპორტი", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "ფოტოების იმპორტისთვის გამოიყენეთ ფაილი → ფოტოების იმპორტი… (Cmd+Shift+I), ან ჩააგდეთ ისინი აქ", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "პრესეტების იმპორტი სხვა რედაქტორებიდან: XMP პრესეტები, კლასიკური `.lrtemplate` ფაილები, მობილური აპების „DNG პრესეტები“ და `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "იმპორტი → კოპირება ყოველ ასლს ამოწმებს, ისევე როგორც გადატანა (ხარვეზი #96): ყოველი ფაილი ახალ ფაილად იწერება, დისკზე სინქრონიზდება და", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "იმპორტი → გადატანა: ფოტოები დანიშნულების ადგილას გადადის (იგივე საქაღალდეებითა და გადარქმევით, რაც კოპირებისას, მაგ.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "იმპორტი: კოპირება ნებისმიერ საქაღალდეში, დღეების / თვეების მიხედვით / ერთ საქაღალდეში, გადარქმევა იმპორტისას, მეტამონაცემების პრესეტი, კოპირება DNG-ად.", + "Imported": "იმპორტირებული", + "Imported Photos": "იმპორტირებული ფოტოები", + "Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay": "იმპორტირებული `.cube` LUT პროფილები ჩანს პროფილის მენიუსა და პროფილების ბრაუზერში, საქაღალდის მიხედვით დაჯგუფებული, და", + "Importing": "იმპორტი", + "Importing Lightroom Catalog": "Lightroom-ის კატალოგის იმპორტი", + "Importing Lightroom catalog": "Lightroom-ის კატალოგის იმპორტი", + "Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot": "XMP პრესეტების იმპორტი აღარ ჩამოთვლის სამსახურებრივ ველებს (`Cluster`, `SortName`, `SupportsAmount2`, გადაღებისას", + "Importing a deleted photo again": "წაშლილი ფოტოს ხელახლა იმპორტი", + "Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums": "ფაილის იმპორტი, რომლის ფოტოც ახლახან წაშლილშია, ფოტოს აბრუნებს მისი ცვლილებებით, შეფასებითა და ალბომებით", + "Importing a large collection and opening its library no longer compare every preset with every other.": "დიდი კოლექციის იმპორტი და მისი ბიბლიოთეკის გახსნა აღარ ადარებს ყველა პრესეტს ყველა დანარჩენს.", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "იმპორტი წყაროში ფოტოებს ეძებს; საქაღალდეს ლოკალურში არ ამატებს (საქაღალდეში იმპორტის გარეშე სამუშაოდ გამოიყენეთ ლოკალური → საქაღალდის დათვალიერება…).", + "In Color": "ფერადი", + "In Photo Grid and Square Grid: Flag as Pick and advance": "ფოტოების ბადესა და კვადრატულ ბადეში: რჩეულად მონიშვნა და შემდეგზე გადასვლა", + "In Photo Grid and Square Grid: expand or collapse the stack": "ფოტოების ბადესა და კვადრატულ ბადეში: დასტის გაშლა ან ჩაკეცვა", + "In Recently Deleted": "ახლახან წაშლილში", + "In Use": "გამოიყენება", + "In a Stack": "დასტაში", + "In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see": "გრძელ სექციაში სათაური გვერდითი ზოლის თავში რჩება, სანამ მის სტრიქონებს გადაახვევთ, ასე რომ ყოველთვის ხედავთ,", + "In library": "ბიბლიოთეკაში", + "In memory — nothing is saved": "მეხსიერებაში — არაფერი ინახება", + "In use": "გამოიყენება", + "In-memory session: these settings last until LightCraft quits.": "სესია მეხსიერებაში: ეს პარამეტრები LightCraft-იდან გასვლამდე მოქმედებს.", + "Include on Export": "ექსპორტისას ჩართვა", + "Include selected photos": "მონიშნული ფოტოების ჩართვა", + "Include subfolders": "ქვესაქაღალდეების ჩართვა", + "Increase Brush Feather": "ფუნჯის კიდეების შერბილების გაზრდა", + "Increase Brush Size": "ფუნჯის ზომის გაზრდა", + "Increase Rating": "შეფასების გაზრდა", + "Index Faces": "სახეების ინდექსირება", + "Index Faces in the Background": "სახეების ინდექსირება ფონურ რეჟიმში", + "Info": "ინფორმაცია", + "Info (I)": "ინფორმაცია (I)", + "Info Overlay Off": "ინფორმაციის ზედდების გამორთვა", + "Info Overlay: Exposure": "ინფორმაციის ზედდება: ექსპოზიცია", + "Info Overlay: File": "ინფორმაციის ზედდება: ფაილი", + "Info overlay": "ინფორმაციის ზედდება", + "Insert at the cursor": "კურსორის ადგილას ჩასმა", + "Inside This Keyword, Purge Unused Keywords.": "ამ საკვანძო სიტყვის შიგნით, გამოუყენებელი საკვანძო სიტყვების გაწმენდა.", + "Inspect Denoise Model": "ხმაურის მოცილების მოდელის შემოწმება", + "Inspect Face Model File": "სახეების მოდელის ფაილის შემოწმება", + "Inspect Lightroom Catalog": "Lightroom-ის კატალოგის შემოწმება", + "Inspect Photo": "ფოტოს შემოწმება", + "Inspecting Lightroom Catalog": "Lightroom-ის კატალოგი მოწმდება", + "Install Denoise Model": "ხმაურის მოცილების მოდელის ინსტალაცია", + "Install Face Model": "სახეების მოდელის ინსტალაცია", + "Install a denoise model to enable AI Denoise on this photo": "ამ ფოტოზე AI ხმაურის მოცილების ჩასართავად დააინსტალირეთ ხმაურის მოცილების მოდელი", + "Install from file…": "ინსტალაცია ფაილიდან…", + "Install the SAM 3 Model": "SAM 3 მოდელის ინსტალაცია", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "დააინსტალირეთ საკუთარი ONNX მოდელი, გვერდით denoise-model.json ფაილით. ეს ვერსია მოდელების ჩამოტვირთვას არ გთავაზობთ.", + "Instant ’70s": "მყისიერი ’70-იანები", + "Intent:": "განზრახვა:", + "Interface": "ინტერფეისი", + "Interface language": "ინტერფეისის ენა", + "Intersect": "გადაკვეთა", + "Intersect with": "გადაკვეთა:", + "Into one folder": "ერთ საქაღალდეში", + "Invert": "ინვერსია", + "Invert Component": "კომპონენტის ინვერსია", + "Invert Mask": "ნიღბის ინვერსია", + "It tests this album, so testing it back would loop": "ის ამ ალბომს ამოწმებს, ამიტომ მისი უკუშემოწმება ჩაიციკლებოდა", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "ხმაურმოცილებული სურათი ჯერ არ შექმნილა, ხოლო სურათები თავისით არ იქმნება (პარამეტრები, AI ხმაურის მოცილება).", + "JPEG (Large)": "JPEG (დიდი)", + "JPEG (Small)": "JPEG (პატარა)", + "JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);": "JPEG XL-ით შეკუმშული DNG-ები (DNG 1.7) ახლა იხსნება: უდანაკარგო ფილები ნიმუშ-ნიმუშ დეკოდირდება (შემოწმებულია სინთეზურ ფაილებზე);", + "JPEG XL export": "JPEG XL ექსპორტი", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG იმავე სახელით (`IMG_0001.CR3` + `IMG_0001.JPG`) აღარ იზიარებს ერთ თანმხლებ ფაილს: RAW ინარჩუნებს `IMG_0001.xmp`-ს,", + "Join the ArtCraft Discord": "შემოუერთდით ArtCraft-ის Discord-ს", + "Join the ArtCraft Discord…": "შემოუერთდით ArtCraft-ის Discord-ს…", + "Join the ArtCraft community on Discord": "შემოუერთდით ArtCraft-ის საზოგადოებას Discord-ზე", + "Keep in Local": "ლოკალურში დატოვება", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "შეინახეთ თავისუფალი ადგილის მქონე დისკზე: ჭკვიანი წინასწარი ხედი დაახლოებით 1 MB-ია თითო ფოტოზე. ესკიზების ქეში ბიბლიოთეკაში რჩება.", + "Kelvin white balance for more Sony cameras": "თეთრის ბალანსი კელვინებში მეტი Sony კამერისთვის", + "Kelvin white balance for raws whose camera has colour matrices": "თეთრის ბალანსი კელვინებში იმ RAW-ებისთვის, რომელთა კამერასაც ფერთა მატრიცები აქვს", + "Keyboard Shortcuts": "კლავიატურის მალსახმობები", + "Keyboard shortcuts": "კლავიატურის მალსახმობები", + "Keyword": "საკვანძო სიტყვა", + "Keyword Count": "საკვანძო სიტყვების რაოდენობა", + "Keyword Info": "საკვანძო სიტყვის ინფორმაცია", + "Keyword List": "საკვანძო სიტყვების სია", + "Keyword Lists": "საკვანძო სიტყვების სიები", + "Keyword Name": "საკვანძო სიტყვის სახელი", + "Keyword Painter": "საკვანძო სიტყვების ფუნჯი", + "Keyword Set": "საკვანძო სიტყვების ნაკრები", + "Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it (or saves the edits as a new": "საკვანძო სიტყვების ნაკრები → ნაკრების რედაქტირება… არედაქტირებს ნაკრების ცხრა საკვანძო სიტყვას ადგილზე, თითო ველი თითო ⌥ კლავიშზე, და გადაარქმევს მას (ან ცვლილებებს ახალ", + "Keyword Sets": "საკვანძო სიტყვების ნაკრებები", + "Keyword Suggestions": "შემოთავაზებული საკვანძო სიტყვები", + "Keyword Tag Options": "საკვანძო სიტყვის პარამეტრები", + "Keyword lists": "საკვანძო სიტყვების სიები", + "Keyword sets": "საკვანძო სიტყვების ნაკრებები", + "Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export": "საკვანძო სიტყვის პარამეტრები განსაზღვრავს, რას შეიცავს ექსპორტირებული ფაილები: ექსპორტში ჩართვა, მშობელი საკვანძო სიტყვების ექსპორტი,", + "Keyword to paint": "მისანიჭებელი საკვანძო სიტყვა", + "Keywording box": "საკვანძო სიტყვების ველი", + "Keywords": "საკვანძო სიტყვები", + "Keywords & People": "საკვანძო სიტყვები და ადამიანები", + "Keywords (K)": "საკვანძო სიტყვები (K)", + "Keywords (⌥1–⌥9)": "საკვანძო სიტყვები (⌥1–⌥9)", + "Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.": "საკვანძო სიტყვების სათაურს აქვს +, როგორც ალბომებს: საკვანძო სიტყვის შექმნა საკვანძო სიტყვების სიის გაუხსნელად.", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "თქვენ მიერ დამატებული საკვანძო სიტყვები აქ ჩნდება; ⌥1–⌥9 მათ მიანიჭებს.", + "LUT-based": "LUT-ზე დაფუძნებული", + "Label": "იარლიყი", + "Label Set": "იარლიყების ნაკრები", + "Landscape": "პეიზაჟი", + "Landscape (wide)": "ჰორიზონტალური (ფართო)", + "Language": "ენა", + "Languages": "ენები", + "Large preset collections": "პრესეტების დიდი კოლექციები", + "Last": "ბოლო", + "Last GPU fallback": "GPU-დან ბოლო გადართვა", + "Last commit": "ბოლო კომიტი", + "Last loupe render": "ლუპის ბოლო რენდერი", + "Last view": "ბოლო ხედი", + "Lateral Chromatic Aberration": "განივი ქრომატული აბერაცია", + "Layer": "შრე", + "Layers": "შრეები", + "Leaf and Hasselblad raws": "Leaf-ისა და Hasselblad-ის RAW ფაილები", + "Leave them": "მათი დატოვება", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "მარცხნივ: სტანდარტული (SDR) დიაპაზონი SDR თეთრამდე. მარჯვნივ: HDR დიაპაზონი, თითო ზოლი SDR თეთრზე ზემოთ თითო საფეხურზე (ნარინჯისფერი ხაზი: HDR მარაგი). როცა HDR დიაპაზონის ვიზუალიზაცია ჩართულია, ზოლები მის ფერებს იღებს.", + "Lens": "ობიექტივი", + "Lens Blur": "ლინზის ბუნდოვნება", + "Lens Corrections": "ობიექტივის კორექციები", + "Level": "დონე", + "Library": "ბიბლიოთეკა", + "Library Folders": "ბიბლიოთეკის საქაღალდეები", + "Library Info": "ბიბლიოთეკის ინფორმაცია", + "Library Originals": "ბიბლიოთეკის ორიგინალები", + "Library Preferences": "ბიბლიოთეკის პარამეტრები", + "Library State": "ბიბლიოთეკის მდგომარეობა", + "Library and views": "ბიბლიოთეკა და ხედები", + "Library keyboard culling": "ბიბლიოთეკაში კლავიატურით გადარჩევა", + "Library name": "ბიბლიოთეკის სახელი", + "Library saved": "ბიბლიოთეკა შენახულია", + "Library sorting": "ბიბლიოთეკის სორტირება", + "Licence:": "ლიცენზია:", + "Light": "სინათლე", + "Light Mode": "ღია რეჟიმი", + "Light Theme": "ღია თემა", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 და უფრო ძველი ვერსიები მას არ იღებს (\"unsupported format … v2\").", + "LightCraft Default": "LightCraft-ის ნაგულისხმევი", + "LightCraft Help": "LightCraft-ის დახმარება", + "LightCraft Preset": "LightCraft-ის პრესეტი", + "LightCraft Presets": "LightCraft-ის პრესეტები", + "LightCraft Website": "LightCraft-ის ვებსაიტი", + "LightCraft could not start": "LightCraft ვერ გაეშვა", + "LightCraft couldn't find where to keep your library.": "LightCraft-მა ვერ იპოვა, სად შეინახოს თქვენი ბიბლიოთეკა.", + "LightCraft couldn't open the library at": "LightCraft-მა ვერ გახსნა ბიბლიოთეკა მისამართზე", + "LightCraft does not know this model, so it assumed:": "LightCraft-მა ეს მოდელი არ იცის, ამიტომ ივარაუდა:", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft აღარ ითიშება გაშვებისას Windows კომპიუტერებზე, რომელთა Vulkan დრაივერი გაუმართავია (ხარვეზი #136, მაგ. ზოგიერთი Intel UHD", + "LightCraft on GitHub": "LightCraft GitHub-ზე", + "LightCraft program\" although nothing had it open. Nothing in the shipped app is known to hit the window today; it": "LightCraft-ის პროგრამაში“, თუმცა არაფერს ჰქონდა ის გახსნილი. გავრცელებული აპი, რამდენადაც ცნობილია, დღეს ამ ფანჯარას ვერ ხვდება;", + "LightCraft the copy shows what you saved.": "LightCraft-ში, ასლი აჩვენებს იმას, რაც შეინახეთ.", + "LightCraft was restarted. The bug is still written to the log (Help ▸ Open Log Folder) for a report.": "სანამ LightCraft ხელახლა არ გაეშვებოდა. ხარვეზი კვლავ იწერება ჟურნალში (დახმარება → ჟურნალების საქაღალდის გახსნა) ანგარიშისთვის.", + "LightCraft website": "LightCraft-ის ვებსაიტი", + "LightCraft — Second Window": "LightCraft — მეორე ფანჯარა", + "LightCraft. Importing adds the keywords you don't have, in one undo step; a file it can't read says which line, or": "LightCraft-ს შორის გადადის. იმპორტი ამატებს საკვანძო სიტყვებს, რომლებიც არ გაქვთ, ერთ დაბრუნების ნაბიჯად; ფაილი, რომელსაც ვერ კითხულობს, ამბობს რომელ სტრიქონზე, ან", + "Lightroom Classic Catalog": "Lightroom Classic-ის კატალოგი", + "Lightroom Classic catalogs": "Lightroom Classic-ის კატალოგები", + "Lightroom import cancelled": "Lightroom-ის იმპორტი გაუქმდა", + "Lightroom inspection cancelled": "Lightroom-ის შემოწმება გაუქმდა", + "Lightroom's `crs:Temperature` for these raws is read as the Kelvin it is (sidecars, Lightroom catalogs, presets);": "Lightroom-ის `crs:Temperature` ამ RAW-ებისთვის იკითხება როგორც კელვინი (თანმხლები ფაილები, Lightroom-ის კატალოგები, პრესეტები);", + "Lights": "ნათელი ტონები", + "Lights Out": "სინათლის ჩაქრობა", + "Limit": "ზღვარი", + "Limit file size (KB, 0 = off)": "ფაილის ზომის შეზღუდვა (KB, 0 = გამორთული)", + "Line delta": "სტრიქონების სხვაობა", + "Linear": "წრფივი", + "Linear Gradient": "წრფივი გრადიენტი", + "Lines +/−": "სტრიქონები +/−", + "Lines added": "დამატებული სტრიქონები", + "Lines deleted": "წაშლილი სტრიქონები", + "List Albums": "ალბომების სია", + "List Develop Controls": "დამუშავების მართვის ელემენტების სია", + "List History": "ისტორიის სია", + "List Presets": "პრესეტების სია", + "List Profiles": "პროფილების სია", + "List this folder in Local from now on": "ამიერიდან ამ საქაღალდის ჩვენება ლოკალურში", + "Loading preview…": "წინასწარი ხედი იტვირთება…", + "Local": "ლოკალური", + "Local folders": "ლოკალური საქაღალდეები", + "Local library — no cloud account needed": "ლოკალური ბიბლიოთეკა — ღრუბლოვანი ანგარიში საჭირო არ არის", + "Locate Missing Files…": "დაკარგული ფაილების მოძებნა…", + "Locate Missing File…": "დაკარგული ფაილის მოძებნა…", + "Locate Photo": "ფოტოს მოძებნა", + "Location": "მდებარეობა", + "Lock": "ჩაკეტვა", + "Lock Crop Aspect": "ჩამოჭრის თანაფარდობის დაფიქსირება", + "Locked": "ჩაკეტილი", + "Long Edge": "გრძელი გვერდი", + "Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and": "ხანგრძლივი ამოცანები ერთ ადგილას ჩანს, ზედა მარცხნივ, ზედა ზოლის ქვეშ, როგორც Lightroom Classic-ში (ხარვეზი #345): იმპორტი და", + "Looking for changes": "ცვლილებების ძებნა", + "Looking for photos…": "ფოტოების ძებნა…", + "Looking up places": "ადგილების ძებნა", + "Lossless": "უდანაკარგო", + "Lossy DNG files and Smart Previews open as raw photos.": "დანაკარგიანი DNG ფაილები და ჭკვიანი წინასწარი ხედები იხსნება როგორც raw ფოტოები.", + "Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in": "დანაკარგიანი JPEG XL ფაილები Display P3-ში ან Rec. 2020-ში ახლა ნამდვილი ფერებით იხსნება; გაჯერებული ფერები ადრე", + "Low": "დაბალი", + "Luminance": "სიკაშკაშე", + "Luminance Range": "სიკაშკაშის დიაპაზონი", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar-ის სტილები: `.lmp` ფაილები და `.mplumpack` კოლექციები იმპორტირდება როგორც პრესეტები (დაჯგუფებული კოლექციის მიხედვით); სლაიდერები", + "Lush Greens": "ხასხასა სიმწვანე", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50-ზე, R100-სა და R8-ზე. CR3 ფაილები, რომლებსაც დეკოდერი ჯერ ვერ კითხულობს, კვლავ ჩაშენებული JPEG-იდან იხსნება, როგორც ადრე.", + "Magenta": "მეწამული", + "Magenta Hue": "მეწამულის ფერის ტონი", + "Magenta Luminance": "მეწამულის სიკაშკაშე", + "Magenta Saturation": "მეწამულის გაჯერება", + "Make Candidate the Select": "კანდიდატის მონიშნულად დაყენება", + "Make it now": "ახლავე შექმნა", + "Make the denoised picture of the photos I am looking at": "ხმაურმოცილებული სურათის შექმნა იმ ფოტოებისთვის, რომლებსაც ვათვალიერებ", + "Manual": "ხელით", + "Marquee zoom": "მასშტაბირება ჩარჩოთი", + "Mask Adjustment": "ნიღბის კორექცია", + "Masking": "ნიღბვა", + "Masking (M)": "ნიღბვა (M)", + "Masks": "ნიღბები", + "Match": "დამთხვევა", + "Match Total Exposures": "ჯამური ექსპოზიციების გათანაბრება", + "Matrix/TRC": "Matrix/TRC", + "Matte": "მატი", + "Matte Soft": "რბილი მატი", + "Med": "საშ.", + "Medium Contrast": "საშუალო კონტრასტი", + "Megapixels": "მეგაპიქსელი", + "Memory Budget": "მეხსიერების ლიმიტი", + "Memory Usage": "მეხსიერების გამოყენება", + "Memory budget": "მეხსიერების ლიმიტი", + "Memory for caches": "მეხსიერება ქეშისთვის", + "Menu": "მენიუ", + "Merge": "შერწყმა", + "Merge Keywords": "საკვანძო სიტყვების შერწყმა", + "Merge into…": "შერწყმა სხვასთან…", + "Merged": "შერწყმულია", + "Merged PRs": "გაერთიანებული PR-ები", + "Merging": "შერწყმა", + "Merging…": "გაერთიანება…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Meta-ს პირობები და არა LightCraft-ის. ჩამოტვირთვა მათ მიღებას ნიშნავს.", + "Metadata": "მეტამონაცემები", + "Metadata Preset": "მეტამონაცემების პრესეტი", + "Metadata Presets": "მეტამონაცემების პრესეტები", + "Metadata preset": "მეტამონაცემების პრესეტი", + "Metadata read": "მეტამონაცემები წაკითხულია", + "Midnight": "შუაღამე", + "Midpoint": "შუა წერტილი", + "Midtones": "შუალედური ტონები", + "Midtones Hue": "შუალედური ტონების ფერის ტონი", + "Midtones Luminance": "შუალედური ტონების სიკაშკაშე", + "Midtones Saturation": "შუალედური ტონების გაჯერება", + "Midtones split": "შუალედური ტონების გაყოფა", + "Minimize": "ჩაკეცვა", + "Missing Photos": "დაკარგული ფოტოები", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "დაკარგული მეტამონაცემები ({camera}, {title}…) ცარიელ ადგილს ტოვებს; თუ სახელი ცარიელი გამოვა, ორიგინალური სახელი რჩება.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "დაკარგული ფოტოები გადადის ახლახან წაშლილში და მათი აღდგენა შეიძლება; დისკზე არცერთ ფაილს არაფერი ეხება.", + "Misty Morning": "ნისლიანი დილა", + "Mo": "ორშ", + "Mode": "რეჟიმი", + "Model": "მოდელი", + "Models": "მოდელები", + "Models are installed separately after accepting their terms; no weights are bundled. See [setup and limitations](denoise.md).": "მოდელები ცალკე დგება მათი პირობების მიღების შემდეგ; წონები არ არის ჩაშენებული. იხ. [დაყენება და შეზღუდვები](denoise.md).", + "Modified Date": "ცვლილების თარიღი", + "Monday": "ორშაბათი", + "Monitor profile": "მონიტორის პროფილი", + "Monitor profiles": "მონიტორის პროფილები", + "Mono Red Filter": "მონოქრომული წითელი ფილტრი", + "Mono Rich": "მდიდარი მონოქრომი", + "Mono Soft": "რბილი მონოქრომი", + "Monochrome": "მონოქრომული", + "Month": "თვე", + "Moody": "პირქუში", + "Moody Portrait": "პირქუში პორტრეტი", + "More": "მეტი", + "More preset options": "პრესეტის სხვა პარამეტრები", + "Move": "გადაადგილება", + "Move Album": "ალბომის გადატანა", + "Move Down": "ჩამოწევა", + "Move Down in Stack": "დასტაში ჩამოწევა", + "Move Folder": "საქაღალდის გადატანა", + "Move Folder To…": "საქაღალდის გადატანა…", + "Move Keyword": "საკვანძო სიტყვის გადატანა", + "Move Mask": "ნიღბის გადაადგილება", + "Move Preset to Group": "პრესეტის ჯგუფში გადატანა", + "Move Preset to New Group": "პრესეტის ახალ ჯგუფში გადატანა", + "Move Selected Photos Here": "მონიშნული ფოტოების აქ გადატანა", + "Move Sidebar Section": "გვერდითი ზოლის სექციის გადატანა", + "Move Up": "აწევა", + "Move Up in Stack": "დასტაში აწევა", + "Move in Stack": "დასტაში გადატანა", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "ფაილების გადატანა (ბიბლიოთეკის Originals/-ში ან თქვენ მიერ არჩეულ საქაღალდეში) და წყაროდან წაშლა", + "Move them": "მათი გადატანა", + "Move to": "გადატანა", + "Move to Folder": "საქაღალდეში გადატანა", + "Move to Folder…": "საქაღალდეში გადატანა…", + "Move to Group": "ჯგუფში გადატანა", + "Move to group": "ჯგუფში გადატანა", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "გადატანა: ფაილები გადაიტანება ქვემოთ მითითებულ საქაღალდეში; თითოეული ორიგინალი (და მისი XMP თანმხლები ფაილი) წყაროდან მხოლოდ ასლის შემოწმების შემდეგ იშლება. დუბლიკატები და ფაილები, რომელთა გადატანაც ვერ მოხერხდა, ადგილზე რჩება.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "ამ დისკიდან იმპორტირებულ ყველა ფოტოს ახლახან წაშლილში გადაიტანს; ფაილებს არაფერი ეხება", + "Moves the files and their XMP sidecars into this folder; never overwrites": "ფაილებს და მათ XMP თანმხლებ ფაილებს ამ საქაღალდეში გადაიტანს; არასოდეს გადაწერს", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "ამ საქაღალდიდან იმპორტირებულ ფოტოებს ახლახან წაშლილში გადაიტანს; ფაილებს არაფერი ეხება", + "Muted": "მკრთალი", + "Muted Film": "მკრთალი ფირი", + "My Photos": "ჩემი ფოტოები", + "My Photos Panel": "ჩემი ფოტოების პანელი", + "Name": "სახელი", + "Name (A–Z)": "სახელი (ა–ჰ)", + "Name Face": "სახისთვის სახელის მიცემა", + "Name Faces": "სახეებისთვის სახელების მიცემა", + "Name of the folder the original is in": "საქაღალდის სახელი, რომელშიც ორიგინალია", + "Named": "სახელიანი", + "Named People": "სახელიანი ადამიანები", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "სახელები ჩანს იარლიყების მენიუში, ფილტრების ზოლსა და ინფორმაციის პანელში და იწერება XMP-ში. ცარიელი = ფერის სახელი.", + "Navigator": "ნავიგატორი", + "Needs Work": "დასახვეწია", + "Neon Dusk": "ნეონის ბინდი", + "Neon Night": "ნეონის ღამე", + "Nested group": "ჩადგმული ჯგუფი", + "Neutral": "ნეიტრალური", + "Never": "არასოდეს", + "New": "ახალი", + "New Album": "ახალი ალბომი", + "New Album…": "ახალი ალბომი…", + "New Folder on Disk": "ახალი საქაღალდე დისკზე", + "New Folder on Disk…": "ახალი საქაღალდე დისკზე…", + "New Folder…": "ახალი საქაღალდე…", + "New Group…": "ახალი ჯგუფი…", + "New Library": "ახალი ბიბლიოთეკა", + "New Library: Choose Where to Put It": "ახალი ბიბლიოთეკა: აირჩიეთ, სად განთავსდეს", + "New Library…": "ახალი ბიბლიოთეკა…", + "New Smart Album": "ახალი სმარტ-ალბომი", + "New Smart Album from Filter…": "ახალი სმარტ-ალბომი ფილტრიდან…", + "New Smart Album…": "ახალი სმარტ-ალბომი…", + "New album": "ახალი ალბომი", + "New album…": "ახალი ალბომი…", + "New blank PDF": "ახალი ცარიელი PDF", + "New fields: Keyword Count (\"2 or more\", \"exactly 2\"…), People and People Count (named faces), Shutter Speed (written": "ახალი ველები: საკვანძო სიტყვების რაოდენობა („2 ან მეტი“, „ზუსტად 2“…), ადამიანები და ადამიანების რაოდენობა (სახელდებული სახეები), ჩამკეტის სიჩქარე (იწერება", + "New name": "ახალი სახელი", + "New time": "ახალი დრო", + "New…": "ახალი…", + "Next Appearance Mode": "შემდეგი იერსახის რეჟიმი", + "Next History Step": "ისტორიის შემდეგი ნაბიჯი", + "Next Photo": "შემდეგი ფოტო", + "Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…", + "Night Blue": "ღამის ლურჯი", + "Nikon D850, Canon EOS 5D Mark III and others) now show white balance in Kelvin, as a DNG does and as Lightroom": "Nikon D850, Canon EOS 5D Mark III და სხვები), ახლა თეთრის ბალანსს კელვინებში აჩვენებს, როგორც DNG და როგორც Lightroom:", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) იყენებს intoPIX TicoRAW შეკუმშვას. LightCraft-ს ამ კოდეკის ლიცენზია ამჟამად არ აქვს, ამიტომ ხელმისაწვდომია მხოლოდ კამერის ჩაშენებული JPEG წინასწარი ხედი და არა სენსორის RAW მონაცემები. RAW მონაცემების რედაქტირებისთვის გადაიღეთ უდანაკარგოდ შეკუმშულ NEF-ში ან არსებული HE ფაილები ლიცენზირებული კონვერტერით DNG-ად გარდაქმენით.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF ფაილები, Sony ARW-ის მსგავსად, კამერის საკუთარ JPEG-ზე მორგებული ფერითა და ტონით იწყება, ნაცვლად მკრთალი,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon-ის ფაილები ორად გაყოფილი, განსხვავებულად შეკუმშული ნაწილებით, ჯერჯერობით წინასწარ ხედს იყენებს.)", + "No": "არა", + "No Camera or Card Found": "კამერა ან ბარათი ვერ მოიძებნა", + "No Date Groups": "თარიღის მიხედვით დაჯგუფების გარეშე", + "No Label": "იარლიყის გარეშე", + "No Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკები არ არის", + "No Stars": "ვარსკვლავების გარეშე", + "No Tool": "ხელსაწყოს გარეშე", + "No albums match": "შესაბამისი ალბომი არ არის", + "No albums yet": "ალბომები ჯერ არ არის", + "No automatic versions.": "ავტომატური ვერსიები არ არის.", + "No camera": "კამერის გარეშე", + "No contributor data was built into this copy.": "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული.", + "No date": "თარიღის გარეშე", + "No dust spots found": "მტვრის ლაქები ვერ მოიძებნა", + "No edits yet.": "ცვლილებები ჯერ არ არის.", + "No exposure information": "ექსპოზიციის ინფორმაცია არ არის", + "No filter presets yet": "ფილტრის პრესეტები ჯერ არ არის", + "No folders to browse": "დასათვალიერებელი საქაღალდეები არ არის", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "გრაფიკული პროცესორი ვერ მოიძებნა: LightCraft რენდერს CPU-ზე ასრულებს, რაც გაცილებით ნელია. დეტალები: დახმარება → სისტემის ინფორმაცია.", + "No imported folders yet": "იმპორტირებული საქაღალდეები ჯერ არ არის", + "No keywords are exported": "საკვანძო სიტყვები არ ექსპორტირდება", + "No keywords match": "შესაბამისი საკვანძო სიტყვა არ არის", + "No keywords yet": "საკვანძო სიტყვები ჯერ არ არის", + "No lens data embedded in this file (DNG lens opcodes only).": "ამ ფაილში ობიექტივის მონაცემები ჩაშენებული არ არის (მხოლოდ DNG-ის ობიექტივის ოპკოდები).", + "No masks yet. Choose a mask type above.": "ნიღბები ჯერ არ არის. ზემოთ აირჩიეთ ნიღბის ტიპი.", + "No matching commands": "შესაბამისი ბრძანება ვერ მოიძებნა", + "No matching photos": "შესაბამისი ფოტოები არ არის", + "No metadata found": "მეტამონაცემები ვერ მოიძებნა", + "No model credits were built into this copy.": "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული.", + "No named people in these photos": "ამ ფოტოებზე სახელიანი ადამიანები არ არის", + "No named people yet": "სახელიანი ადამიანები ჯერ არ არის", + "No new presets": "ახალი პრესეტები არ არის", + "No photo has it; the keywords below it go too.": "არცერთ ფოტოს არ აქვს; მასში ჩადგმული საკვანძო სიტყვებიც წაიშლება.", + "No photo selected": "ფოტო არ არის მონიშნული", + "No photos": "ფოტოები არ არის", + "No photos found": "ფოტოები ვერ მოიძებნა", + "No photos in this folder": "ამ საქაღალდეში ფოტოები არ არის", + "No photos match the active filters": "აქტიურ ფილტრებს არცერთი ფოტო არ შეესაბამება", + "No photos match the filters (View → Clear Filters)": "ფილტრებს არცერთი ფოტო არ შეესაბამება (ხედი → ფილტრების გასუფთავება)", + "No photos yet": "ფოტოები ჯერ არ არის", + "No raw photos yet: cameras appear here once their photos are in the library.": "raw ფოტოები ჯერ არ არის: კამერები აქ გამოჩნდება, როცა მათი ფოტოები ბიბლიოთეკაში იქნება.", + "No reference photo": "საცნობარო ფოტო არ არის", + "No usable GPU found: rendering on the CPU": "გამოსადეგი GPU ვერ მოიძებნა: რენდერი CPU-ზე", + "No versions yet. Create one to keep this look.": "ვერსიები ჯერ არ არის. ამ იერსახის შესანახად შექმენით ვერსია.", + "Noise": "ხმაური", + "Noise Reduction": "ხმაურის შემცირება", + "Non-raw photos": "არა-raw ფოტოები", + "None": "არცერთი", + "None (the display is treated as sRGB)": "არცერთი (ეკრანი sRGB-ად ითვლება)", + "Not Now": "ახლა არა", + "Not saved": "არ არის შენახული", + "Nothing already edited changes: a Custom white balance set on the old relative scale renders pixel for pixel as": "არაფერი უკვე დამუშავებული არ იცვლება: ძველ ფარდობით შკალაზე დაყენებული მორგებული თეთრის ბალანსი პიქსელ-პიქსელ ისევე რენდერდება, როგორც", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "თქვენს თანხმობამდე არაფერი ჩამოიტვირთება. მოდელი მხოლოდ ამ კომპიუტერზე ინახება; LightCraft მას არასოდეს ატვირთავს და არავის უზიარებს.", + "Nothing to compare": "შესადარებელი არაფერია", + "Nothing to survey": "მიმოსახილველი არაფერია", + "Nudge Develop Value": "დამუშავების მნიშვნელობის ბიჯით შეცვლა", + "OK": "OK", + "ONNX model": "ONNX მოდელი", + "Object": "ობიექტი", + "Object Mask": "ობიექტის ნიღაბი", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "ნიღბები „ობიექტი“ და „აღწერა“ იყენებს SAM 3-ს, Meta-ს სეგმენტაციის მოდელს. ის LightCraft-ის ნაწილი არ არის და ყველაფერი დანარჩენი მის გარეშეც მუშაობს.", + "October 2026": "ოქტომბერი 2026", + "Off": "გამორთული", + "Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`.": "ნაგულისხმევად გამორთულია (HEVC-ის პატენტები გამავრცელებლის გადასაწყვეტია, როგორც PhotoCraft-ში); ოფიციალური აწყობები `--features heif`-ს იყენებს.", + "Offset X": "X-წანაცვლება", + "Offset Y": "Y-წანაცვლება", + "Older raw cameras": "ძველი RAW კამერები", + "Olympus E-10 raws open with the camera's white balance (White Balance ▸ As Shot) and without the grey haze in": "Olympus E-10-ის RAW ფაილები კამერის თეთრის ბალანსით (თეთრის ბალანსი → როგორც გადაღებულია) და ჩრდილებში", + "Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the": "Olympus ORF RAW-ებიც კამერის საკუთარ JPEG-ზე მორგებულ საწყის იერს იღებს, ბრტყლად და ნაცრისფრად გახსნის ნაცვლად (", + "On Linux and FreeBSD, file dialogs (Import Photos…, Open Library, Export, presets, keyword lists…) are children of": "Linux-სა და FreeBSD-ზე ფაილის დიალოგები (ფოტოების იმპორტი…, ბიბლიოთეკის გახსნა, ექსპორტი, პრესეტები, საკვანძო სიტყვების სიები…)", + "On NixOS, start it from `nix develop`.": "NixOS-ზე გაუშვით `nix develop`-იდან.", + "On Windows and Linux, Ctrl+C outside a text field copies edit settings and Ctrl+V pastes them (⇧Ctrl+V: Paste": "Windows-სა და Linux-ზე Ctrl+C ტექსტური ველის გარეთ რედაქტირების პარამეტრებს აკოპირებს, Ctrl+V კი ჩასვამს (⇧Ctrl+V: მონიშნული", + "On macOS, photos are shown through the profile of the screen the window is on while no monitor profile is chosen in": "macOS-ზე ფოტოები ნაჩვენებია იმ ეკრანის პროფილით, რომელზეც ფანჯარაა, სანამ მონიტორის პროფილი არჩეული არ არის", + "On macOS, ratings `0–5`, labels `6–9` and pick/unflag `P/U` now reach the app even when shown in the native menu (issue #283; adapted from PR #261).": "macOS-ზე შეფასებები `0–5`, იარლიყები `6–9` და რჩეული/დროშის მოხსნა `P/U` ახლა აპს აღწევს მაშინაც, როცა ისინი მშობლიურ მენიუშია (ხარვეზი #283; ადაპტირებულია PR #261-დან).", + "On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,": "macOS-ზე მენიუს ზოლში ნაჩვენები ერთკლავიშიანი მალსახმობები ახლა მუშაობს: E, C, H, M, ⇧P, I, K, D, შეფასებები 0–5,", + "Only": "მხოლოდ", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "მუშავდება მხოლოდ ის ფოტოები, რომელთა AI ხმაურის მოცილების სიძლიერე 0-ზე მეტია. ექსპორტი ყოველთვის ქმნის თავისას, თუ ის აკლია.", + "Opacity": "გაუმჭვირვალობა", + "Opacity (%)": "გაუმჭვირვალობა (%)", + "Open Library": "ბიბლიოთეკის გახსნა", + "Open Library knows a Lightroom catalog": "ბიბლიოთეკის გახსნა ცნობს Lightroom-ის კატალოგს", + "Open Library…": "ბიბლიოთეკის გახსნა…", + "Open Log Folder": "ჟურნალების საქაღალდის გახსნა", + "Open Original in Other Editor (RAW, no copy)": "ორიგინალის გახსნა სხვა რედაქტორში (RAW, ასლის გარეშე)", + "Open Recent Library": "ბოლოს გახსნილი ბიბლიოთეკის გახსნა", + "Open in": "გახსნა:", + "Open in Detail": "დეტალურ ხედში გახსნა", + "Open page": "გვერდის გახსნა", + "Open the model's page": "მოდელის გვერდის გახსნა", + "Open the place in OpenStreetMap": "ადგილის გახსნა OpenStreetMap-ში", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "მოდელის საკუთარ გვერდს თქვენს ბრაუზერში ხსნის, რომ მის შესახებ წაიკითხოთ ან ფაილი თავად აიღოთ.", + "Open…": "გახსნა…", + "Optics": "ოპტიკა", + "Optics, Geometry and Calibration (issue #316).": "ოპტიკის, გეომეტრიის და კალიბრაციისთვის (ხარვეზი #316).", + "Optimize Library": "ბიბლიოთეკის ოპტიმიზაცია", + "Optional name": "არასავალდებულო სახელი", + "Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "არასავალდებულო დისტანციური SAM 3: მშობლიური რედაქტორი ლოკალურად დატოვეთ, ობიექტის, აღწერის და დეტალის", + "Options": "პარამეტრები", + "Orange": "ნარინჯისფერი", + "Orange Hue": "ნარინჯისფერის ფერის ტონი", + "Orange Luminance": "ნარინჯისფერის სიკაშკაშე", + "Orange Saturation": "ნარინჯისფერის გაჯერება", + "Orientation": "ორიენტაცია", + "Original": "ორიგინალი", + "Original + Settings": "ორიგინალი + პარამეტრები", + "Original extension without the dot": "ორიგინალური გაფართოება წერტილის გარეშე", + "Original file name without its extension": "ორიგინალური ფაილის სახელი გაფართოების გარეშე", + "Original files": "ორიგინალური ფაილები", + "Original offline · editing the smart preview": "ორიგინალი მიუწვდომელია · რედაქტირდება ჭკვიანი წინასწარი ხედი", + "Original offline · no smart preview": "ორიგინალი მიუწვდომელია · ჭკვიანი წინასწარი ხედი არ არის", + "Originals": "ორიგინალები", + "Other editor": "სხვა რედაქტორი", + "Other images (JPEG, PNG, TIFF, HEIC…)": "სხვა გამოსახულებები (JPEG, PNG, TIFF, HEIC…)", + "Other…": "სხვა…", + "Outside the crop box, where dragging rotates the photo, the pointer is a curved double arrow, and the angle is shown": "ჩამოჭრის ჩარჩოს გარეთ, სადაც გადათრევა ფოტოს აბრუნებს, მაჩვენებელი მრუდი ორმაგი ისარია, ხოლო კუთხე", + "Overlay": "ზედდება", + "Overwrite": "გადაწერა", + "Pages": "გვერდები", + "Paint": "ხატვა", + "Paint only areas like the one under the brush": "მხოლოდ ფუნჯის ქვეშ მყოფის მსგავსი არეების დახატვა", + "Paint over a distraction on the photo to remove it.": "ფოტოზე ზედმეტ დეტალს გადაუსვით ფუნჯი მის მოსაშორებლად.", + "Paint overlay": "საღებავით გადაფარვა", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Panasonic-ისა და Leica-ს raw ფაილები (RW2, RWL და ძველი RAW ფაილები, DMC-LX1-დან DC-S1R II-მდე) მუშავდება raw მონაცემებიდან", + "Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG": "Panasonic-ისა და Leica-ს RAW-ები (RW2, RWL) ახლა ობიექტივის დეფორმაციისთვის ისე სწორდება, როგორც კამერა საკუთარ JPEG-ს ასწორებს", + "Panels": "პანელები", + "Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on": "1:1-ზე გადიდებული ფოტოს გადაადგილება ან სლაიდერის მოძრაობა გადიდებისას აღარ დეკოდირებს მთელ RAW-ს ყოველ ჯერზე", + "Panorama": "პანორამა", + "Panorama Merge": "პანორამად გაერთიანება", + "Panorama Merge Preview": "პანორამად გაერთიანების გადახედვა", + "Panorama with Last Settings": "პანორამა ბოლო პარამეტრებით", + "Panoramas": "პანორამები", + "Panorama…": "პანორამა…", + "Paper": "ქაღალდი", + "Parametric curve": "პარამეტრული მრუდი", + "Paste": "ჩასმა", + "Paste Edit Settings": "რედაქტირების პარამეტრების ჩასმა", + "Paste Metadata": "მეტამონაცემების ჩასმა", + "Paste Selected Settings": "არჩეული პარამეტრების ჩასმა", + "Paste Selected Settings…": "არჩეული პარამეტრების ჩასმა…", + "Paste Settings": "პარამეტრების ჩასმა", + "Paste Settings from Previous": "წინა ფოტოს პარამეტრების ჩასმა", + "Paste from Previous": "წინადან ჩასმა", + "Paste pages": "გვერდების ჩასმა", + "Pastel Haze": "პასტელური ბურუსი", + "Pending photo action cancelled": "ფოტოს მოლოდინში მყოფი მოქმედება გაუქმდა", + "Pending photo action cancelled because its library or photo changed": "ფოტოს მოლოდინში მყოფი მოქმედება გაუქმდა, რადგან მისი ბიბლიოთეკა ან ფოტო შეიცვალა", + "Pentax raws without black borders": "Pentax-ის RAW ფაილები შავი კიდეების გარეშე", + "People": "ადამიანები", + "People Count": "ადამიანების რაოდენობა", + "Percent": "პროცენტული", + "Percentage": "პროცენტული", + "Perceptual": "აღქმითი", + "Performance": "წარმადობა", + "Person": "ადამიანი", + "Perspective": "პერსპექტივა", + "Pet Eye": "ცხოველის თვალები", + "Pet Eye Catchlight": "ცხოველის თვალის ანარეკლი", + "Photo": "ფოტო", + "Photo Above": "ზემოთ მდებარე ფოტო", + "Photo Below": "ქვემოთ მდებარე ფოტო", + "Photo Grid": "ფოტოების ბადე", + "Photo Grid (G)": "ფოტოების ბადე (G)", + "Photo Merge": "ფოტოების გაერთიანება", + "Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and": "Canon CR2-ის (და სხვა RAW ფაილების, რომელთა ფერსაც LightCraft კამერის საკუთარი JPEG-იდან არგებს, როგორიცაა NEF და", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "„ფოტო“ მენიუში. კადრების ზოლსაც აქვს ფოტოს კონტექსტური მენიუ. ახლახან წაშლილში მყოფი ფაილის ხელახლა დამატება აღარ", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "ფოტო → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (⇧⌘E, ან მარჯვენა დაწკაპუნება ფოტოზე) არენდერებს ასლს თქვენი ცვლილებებით, ორიგინალთან ერთ დასტაში აერთიანებს და PhotoCraft-ში ხსნის. იქ შენახვისას ასლი აქაც განახლდება. PSD-ის შემთხვევაში PhotoCraft პირდაპირ მასში ინახავს. მდებარეობა ცარიელი დატოვეთ, თუ პორტატულ ასლს არ იყენებთ.", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "ფოტო → რედაქტირება გარე რედაქტორში (⇧⌘E) ქმნის 16-ბიტიან TIFF ასლს, ორიგინალთან ერთად დასტაში აერთიანებს და აქ ხსნის (macOS-ზე აპის სახელი, სხვაგან — პროგრამის მისამართი).", + "Photos": "ფოტოები", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "ფოტოები თქვენი მონიტორის ICC პროფილით ნაჩვენებია, ამიტომ ფერები სწორია ფართო ფერთა გამისა და დაკალიბრებულ ეკრანებზე, ხოლო დეტალური ხედი ეკრანის მთელ ფერთა გამას იყენებს. ჰისტოგრამაზე, წინასწარი ხედების ქეშებსა და ექსპორტზე ეს გავლენას არ ახდენს.", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "ახლახან წაშლილში მყოფი ფოტოების აღდგენა შეიძლება აპიდან: მარჯვენა დაწკაპუნება → აღდგენა (ან სამუდამოდ წაშლა), აგრეთვე", + "Photos per bracket (0 = auto)": "ფოტო ექსპოზიციის სერიაში (0 = ავტომატური)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "ლოკალურში ნანახი, მაგრამ არასოდეს დამატებული ან შეცვლილი ფოტოები კატალოგიდან ამოიშლება, თუ მათი საქაღალდე ამ ხნის განმავლობაში არ დაგითვალიერებიათ (მოწმდება ბიბლიოთეკის გახსნისას). ფაილები დისკზე რჩება; საქაღალდის დათვალიერება მათ ისევ აჩვენებს.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "ფოტოები „ყველა ფოტოში“ რჩება. საქაღალდის წაშლა მის ალბომებსაც შლის. ამის დაბრუნება შეიძლება.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "ამ საქაღალდეში შემოსული ფოტოები (კამერასთან პირდაპირი კავშირი, სკანერი, სინქრონიზაციის აპი) ემატება, როგორც კი სრულად ჩაიწერება.", + "Photos that tie on the sort key (equal ratings, one import batch, a burst shot within the same second) now sort by": "ფოტოები, რომლებიც სორტირების გასაღებით თანაბარია (თანაბარი შეფასება, ერთი იმპორტის პარტია, ერთსა და იმავე წამში გადაღებული სერია), ახლა სორტირდება", + "Photos with deep shadows no longer have to extend the first measured tone in a straight line to black.": "ღრმა ჩრდილების მქონე ფოტოებში პირველი გაზომილი ტონი აღარ უნდა გაგრძელდეს სწორი ხაზით შავამდე.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "ფოტოები); იმპორტი (გადათრევითაც), დაკარგული ფოტოების მოძებნა, ჭკვიანი წინასწარი ხედების შექმნა / მოცილება, გადარქმევის გადახედვა,", + "Pick": "რჩეული", + "Pick Flag": "რჩეულის დროშა", + "Pick a colour on the photo with the eyedropper.": "ფოტოზე ფერის არჩევა პიპეტით.", + "Pick a date": "თარიღის არჩევა", + "Pick the sharpest photo of each burst": "თითოეული სერიიდან ყველაზე მკვეთრი ფოტოს არჩევა", + "Picked": "რჩეული", + "Picks": "რჩეულები", + "Pictures": "სურათები", + "Pixels": "პიქსელი", + "Place Names Status": "ადგილების სახელების სტატუსი", + "Place names download cancelled": "ადგილების სახელების ჩამოტვირთვა გაუქმდა", + "Place names downloaded": "ადგილების სახელები ჩამოტვირთულია", + "Point Color": "წერტილის ფერი", + "Point Curve": "წერტილოვანი მრუდი", + "Point Curve Presets": "წერტილოვანი მრუდის პრესეტები", + "Point curve": "წერტილოვანი მრუდი", + "Portrait": "პორტრეტი", + "Portrait (tall)": "ვერტიკალური (მაღალი)", + "Position": "პოზიცია", + "Preferences": "პარამეტრები", + "Preferences…": "პარამეტრები…", + "Prepare AI Masks": "AI ნიღბების მომზადება", + "Preset": "პრესეტი", + "Preset name": "პრესეტის სახელი", + "Presets": "პრესეტები", + "Presets & Profiles": "პრესეტები და პროფილები", + "Presets (Shift+P)": "პრესეტები (Shift+P)", + "Presets and profiles": "პრესეტები და პროფილები", + "Presets ▾": "პრესეტები →", + "Press keys…": "დააჭირეთ კლავიშებს…", + "Preview": "გადახედვა", + "Preview Build Progress": "წინასწარი ხედების შექმნის პროგრესი", + "Preview only": "მხოლოდ წინასწარი ხედი", + "Preview only: editing the camera's embedded JPEG": "მხოლოდ წინასწარი ხედი: რედაქტირდება კამერის ჩაშენებული JPEG", + "Preview or Photos. Moving the window to another screen is followed; **Use Screen Profile** goes back to it after": "Preview-სა ან Photos-ში. ფანჯრის სხვა ეკრანზე გადატანაც გაითვალისწინება; **ეკრანის პროფილის გამოყენება** მას უბრუნდება", + "Preview size": "წინასწარი ხედის ზომა", + "Preview unavailable": "წინასწარი ხედი მიუწვდომელია", + "Previews": "წინასწარი ხედები", + "Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე", + "Previous History Step": "ისტორიის წინა ნაბიჯი", + "Previous Photo": "წინა ფოტო", + "Primaries (x, y)": "ძირითადი ფერები (x, y)", + "Print": "ბეჭდვა", + "Print…": "დაბეჭდვა…", + "ProRAW the way the iPhone renders it, turn on Profile ▸ Camera local tone mapping (shown for photos that carry the": "ProRAW-ის ისე სანახავად, როგორც iPhone რენდერავს, ჩართეთ პროფილი → კამერის ლოკალური ტონალური ასახვა (ჩანს ფოტოებზე, რომლებსაც", + "Process. See [process versions](process-versions.md).": "პროცესზე განახლებას. იხ. [პროცესის ვერსიები](process-versions.md).", + "Processor": "პროცესორი", + "Profile": "პროფილი", + "Profile Browser": "პროფილების ბრაუზერი", + "Profile Distortion": "დეფორმაცია პროფილით", + "Profile Menu": "პროფილების მენიუ", + "Profile Vignetting": "ვინიეტირება პროფილით", + "Profile:": "პროფილი:", + "Profiles": "პროფილები", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "სამუშაო მაგიდის ფერის პარამეტრებში მინიჭებული პროფილები ჩვეულებრივ ~/.local/share/icc-შია.", + "Progressive": "პროგრესული", + "Projection": "პროექცია", + "Proof copy created": "საცდელი ასლი შექმნილია", + "Properties": "თვისებები", + "Public Domain": "საზოგადოებრივი საკუთრება", + "Public domain": "საზოგადოებრივი საკუთრება", + "Pupil Size": "გუგის ზომა", + "Purge Unused Keywords": "გამოუყენებელი საკვანძო სიტყვების გაწმენდა", + "Purple": "იისფერი", + "Purple Amount": "იისფერის სიძლიერე", + "Purple Hue": "იისფერის ფერის ტონი", + "Purple Hue High": "იისფერის ფერის ტონის ზედა ზღვარი", + "Purple Hue Low": "იისფერის ფერის ტონის ქვედა ზღვარი", + "Purple Luminance": "იისფერის სიკაშკაშე", + "Purple Saturation": "იისფერის გაჯერება", + "Put New Keywords Inside": "ახალი საკვანძო სიტყვების შიგნით მოთავსება", + "Put New Keywords Inside This Keyword": "ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა", + "Put the locations you removed from Local back (no files change)": "ლოკალურიდან მოშორებული მდებარეობების დაბრუნება (ფაილები არ იცვლება)", + "Quality": "ხარისხი", + "Query Photos": "ფოტოების მოთხოვნა", + "Quick Collection": "სწრაფი კოლექცია", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "სწრაფი კოლექცია და სამიზნე ალბომი (B ბადეში), საკვანძო სიტყვების ნაკრებები (⌥1–⌥9), ფერადი იარლიყების ნაკრებები.", + "Quick Develop": "სწრაფი დამუშავება", + "Quiet Green": "მშვიდი სიმწვანე", + "Quit": "გასვლა", + "Quit Anyway": "მაინც გასვლა", + "Quit LightCraft": "LightCraft-იდან გასვლა", + "Quit while tasks are running?": "გავიდეთ, სანამ ამოცანები სრულდება?", + "Quit with unsaved changes?": "გავიდეთ შეუნახავი ცვლილებებით?", + "Quitting while a task that can be stopped is running asks first (\"Quit Anyway\" stops it).": "გასვლა, როცა შეჩერებადი ამოცანა მიმდინარეობს, ჯერ კითხულობს („მაინც გასვლა“ მას აჩერებს).", + "RAW decoding": "RAW-ის დეკოდირება", + "Radial": "რადიალური", + "Radial Gradient": "რადიალური გრადიენტი", + "Radius": "რადიუსი", + "Random": "შემთხვევითი", + "Range": "დიაპაზონი", + "Rate ★": "შეფასება ★", + "Rate ★ and advance": "შეფასება ★ და გადასვლა", + "Rate ★★": "შეფასება ★★", + "Rate ★★ and advance": "შეფასება ★★ და გადასვლა", + "Rate ★★★": "შეფასება ★★★", + "Rate ★★★ and advance": "შეფასება ★★★ და გადასვლა", + "Rate ★★★★": "შეფასება ★★★★", + "Rate ★★★★ and advance": "შეფასება ★★★★ და გადასვლა", + "Rate ★★★★★": "შეფასება ★★★★★", + "Rate ★★★★★ and advance": "შეფასება ★★★★★ და გადასვლა", + "Rating": "შეფასება", + "Rating cleared": "შეფასება გასუფთავდა", + "Ratings & flags": "შეფასებები და დროშები", + "Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;": "შეფასებები, დროშები, იარლიყები, საკვანძო სიტყვები, კოლექციები/ნაკრებები, ვირტუალური ასლები და მხარდაჭერილი ცვლილებები გადმოდის;", + "Raw": "Raw", + "Raw default": "raw-ის ნაგულისხმევი", + "Raw defaults": "raw-ის ნაგულისხმევები", + "Raw files": "raw ფაილები", + "Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS": "RAW ფაილები, რომელთა RAW მონაცემები TIFF-ის კერძო ბლოკშია (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds და Kodak DCS", + "Raw photos": "raw ფოტოები", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "raw ფოტოები DNG-ად, ჩაშენებული ცვლილებებით. ზომის, ფერისა და გამოტანის პარამეტრები არ მოქმედებს.", + "Raws from 52 camera models (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus their other names) whose": "52 კამერის მოდელის RAW-ები (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm და 5 სხვა, მათი სხვა სახელებითურთ), რომელთა", + "Raws from the 52 camera models whose colour matrices LightCraft has measured (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,": "52 კამერის მოდელის RAW-ები, რომელთა ფერთა მატრიცებიც LightCraft-მა გაზომა (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,", + "Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer": "Pentax KP, KF, K-3 Mark III (და Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 და 645Z კამერების RAW ფაილები აღარ", + "Read Metadata from File": "მეტამონაცემების წაკითხვა ფაილიდან", + "Read metadata updates from XMP sidecars": "მეტამონაცემების განახლებების წაკითხვა XMP თანმხლები ფაილებიდან", + "Read mode": "კითხვის რეჟიმი", + "Read the SAM License": "SAM-ის ლიცენზიის წაკითხვა", + "Reading": "იკითხება", + "Reading Lightroom catalog": "Lightroom-ის კატალოგის კითხვა", + "Reading Lightroom catalog…": "Lightroom-ის კატალოგის კითხვა…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "თანმხლები ფაილის წაკითხვა ფოტოს შეფასებას, იარლიყებს, მეტამონაცემებსა და ცვლილებებს მასში ჩაწერილით ჩაანაცვლებს.", + "Reading folder": "საქაღალდის კითხვა", + "Reading folder…": "საქაღალდის კითხვა…", + "Reading photos…": "ფოტოების წაკითხვა…", + "Real name": "ნამდვილი სახელი", + "Recent": "ბოლოდროინდელი", + "Recent Keywords": "ბოლოდროინდელი საკვანძო სიტყვები", + "Recent Keywords is built in: choose another name": "„ბოლოდროინდელი საკვანძო სიტყვები“ ჩაშენებულია: აირჩიეთ სხვა სახელი", + "Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკები", + "Recently Added": "ბოლოს დამატებული", + "Recently Deleted": "ახლახან წაშლილი", + "Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos.": "ახლახან წაშლილი ჩამოთვლილია ყველა ფოტოსთან, ბოლოს დამატებულსა და რჩეულებთან ერთად „ჩემი ფოტოების“ ქვეშ.", + "Recognise faces (experimental)": "სახეების ამოცნობა (ექსპერიმენტული)", + "Recognition models": "ამოცნობის მოდელები", + "Red": "წითელი", + "Red Eye": "წითელი თვალები", + "Red Hue": "წითლის ფერის ტონი", + "Red Luminance": "წითლის სიკაშკაშე", + "Red Primary": "წითელი ძირითადი", + "Red Saturation": "წითლის გაჯერება", + "Red channel": "წითელი არხი", + "Red label": "წითელი იარლიყი", + "Red/Cyan Fringe": "წითელი/ცისფერი შარავანდი", + "Redo": "ხელახლა შესრულება", + "Reference": "ეტალონი", + "Reference View": "ეტალონური ხედი", + "Reference the files where they are": "ფაილებზე მიმართვა მათ ადგილას", + "Refine": "დაზუსტება", + "Refine AI Mask Detail": "AI ნიღბის დეტალების დაზუსტება", + "Refine Edges": "კიდეების დაზუსტება", + "Refine Mask Edges": "ნიღბის კიდეების დაზუსტება", + "Refine Saturation": "გაჯერების დაზუსტება", + "Refining the mask's detail…": "ნიღბის დეტალები ზუსტდება…", + "Refresh Source": "წყაროს განახლება", + "Refresh Source (/)": "წყაროს განახლება (/)", + "Reject": "უარყოფა", + "Reject below focus": "ფოკუსის ზღვარს ქვემოთ უარყოფა", + "Rejected": "უარყოფილი", + "Rejects": "უარყოფილები", + "Relative": "ფარდობითი", + "Reliability": "საიმედოობა", + "Relink Photo": "ფოტოს ხელახლა დაკავშირება", + "Relink photos whose file was renamed or moved": "იმ ფოტოების ხელახლა დაკავშირება, რომელთა ფაილი გადაერქვა ან გადაიტანეს", + "Relinked": "ხელახლა დაკავშირებულია", + "Reload Changed Files": "შეცვლილი ფაილების ხელახლა ჩატვირთვა", + "Reload from Disk": "დისკიდან ხელახლა ჩატვირთვა", + "Remove": "მოშორება", + "Remove (H)": "მოშორება (H)", + "Remove CA": "ქრომატული აბერაციის მოცილება", + "Remove Chromatic Aberration": "ქრომატული აბერაციის მოცილება", + "Remove Denoise Model": "ხმაურის მოცილების მოდელის წაშლა", + "Remove Disk from Library": "დისკის ბიბლიოთეკიდან ამოშლა", + "Remove Disk from Library…": "დისკის ბიბლიოთეკიდან ამოშლა…", + "Remove Face Box": "სახის ჩარჩოს წაშლა", + "Remove Face Model": "სახეების მოდელის წაშლა", + "Remove Folder from Library": "საქაღალდის ბიბლიოთეკიდან ამოშლა", + "Remove a filter above, or choose Clear all": "მოაშორეთ ზემოთ მოცემული ფილტრი ან აირჩიეთ „ყველას გასუფთავება“", + "Remove from Album": "ალბომიდან ამოშლა", + "Remove from Favorites": "რჩეულებიდან ამოშლა", + "Remove from Library…": "ბიბლიოთეკიდან ამოშლა…", + "Remove from Local": "ლოკალურიდან ამოშლა", + "Remove from Photo": "ფოტოდან ამოშლა", + "Remove from Selected Photos": "მონიშნული ფოტოებიდან ამოშლა", + "Remove from Stack": "დასტიდან ამოშლა", + "Remove from Target Album": "სამიზნე ალბომიდან ამოშლა", + "Remove from favorites": "რჩეულებიდან ამოშლა", + "Remove from survey": "მიმოხილვიდან ამოშლა", + "Remove location info": "მდებარეობის ინფორმაციის წაშლა", + "Remove missing photos from the library": "დაკარგული ფოტოების ბიბლიოთეკიდან ამოშლა", + "Remove shortcut": "მალსახმობის წაშლა", + "Remove this face box (undo with Edit ▸ Undo)": "ამ სახის ჩარჩოს წაშლა (დაბრუნება: რედაქტირება → დაბრუნება)", + "Remove this filter": "ამ ფილტრის მოშორება", + "Remove this group": "ამ ჯგუფის წაშლა", + "Remove this rule": "ამ წესის წაშლა", + "Removed": "ამოშლილია", + "Rename": "გადარქმევა", + "Rename Album": "ალბომის გადარქმევა", + "Rename Component": "კომპონენტის გადარქმევა", + "Rename Folder": "საქაღალდის გადარქმევა", + "Rename Folder…": "საქაღალდის გადარქმევა…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "საქაღალდის გადარქმევა… და საქაღალდის გადატანა… (ლოკალური) შეიძლება დაბრუნდეს და ხელახლა შესრულდეს (ხარვეზი #97): საქაღალდე დისკზე ბრუნდება", + "Rename Keyword": "საკვანძო სიტყვის გადარქმევა", + "Rename Keyword…": "საკვანძო სიტყვის გადარქმევა…", + "Rename Mask": "ნიღბის გადარქმევა", + "Rename Photos": "ფოტოების გადარქმევა", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "ფოტოების გადარქმევა აღარასოდეს გადააწერს სხვა ფოტოს, როცა მხოლოდ ასოების რეგისტრი იცვლება (ხარვეზი #95): რეგისტრის მიმართ მგრძნობიარე", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "ფოტოების გადარქმევა აცნობებს ფაილებს, რომელთა უკან დაბრუნებაც შეცდომის შემდეგ ვერ მოხერხდა (ხარვეზი #105), მაგ. როცა ქსელური საქაღალდე ითიშება", + "Rename Photos…": "ფოტოების გადარქმევა…", + "Rename Preset": "პრესეტის გადარქმევა", + "Rename Preview": "გადარქმევის გადახედვა", + "Rename Template Tags": "გადარქმევის შაბლონის ტეგები", + "Rename Version": "ვერსიის გადარქმევა", + "Rename…": "გადარქმევა…", + "Rendering": "რენდერირება", + "Rendering…": "რენდერდება…", + "Reorder Album": "ალბომის გადალაგება", + "Reorder Masks": "ნიღბების გადალაგება", + "Reset": "ჩამოყრა", + "Reset All": "ყველაფრის ჩამოყრა", + "Reset All Curves": "ყველა მრუდის ჩამოყრა", + "Reset All Keyboard Shortcuts": "კლავიატურის ყველა მალსახმობის ჩამოყრა", + "Reset Before to Import State": "„მანამდე“ იმპორტის მდგომარეობაზე დაბრუნება", + "Reset Control": "მართვის ელემენტის ჩამოყრა", + "Reset Crop": "ჩამოჭრის გაუქმება", + "Reset Edits": "ცვლილებების ჩამოყრა", + "Reset Panel Layout": "პანელების განლაგების ჩამოყრა", + "Reset Parametric Curve": "პარამეტრული მრუდის ჩამოყრა", + "Reset Section": "სექციის საწყისზე დაბრუნება", + "Reset Sidebar Sections": "გვერდითი ზოლის სექციების ჩამოყრა", + "Reset Tone Curve": "ტონალური მრუდის ჩამოყრა", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "ყველა მრუდის ჩამოყრა: წერტილოვანი მრუდები (ყველა არხი) და პარამეტრული (ორმაგი დაწკაპუნება არხზე მხოლოდ მას ჩამოყრის)", + "Reset all edits (Cmd+Shift+R)": "ყველა ცვლილების ჩამოყრა (Cmd+Shift+R)", + "Reshuffle": "ხელახლა არევა", + "Resize Face Box": "სახის ჩარჩოს ზომის შეცვლა", + "Resize panel window": "პანელის ფანჯრის ზომის შეცვლა", + "Resize panels": "პანელების ზომის შეცვლა", + "Resize to": "ზომის შეცვლა:", + "Resolution (ppi)": "გარჩევადობა (ppi)", + "Restore": "აღდგენა", + "Restore (no shortcut)": "აღდგენა (მალსახმობის გარეშე)", + "Restore Library from Backup…": "ბიბლიოთეკის აღდგენა სარეზერვო ასლიდან…", + "Restore Version": "ვერსიის აღდგენა", + "Restore keeps their edits; Import as new deletes the old record first.": "აღდგენა ინარჩუნებს მათ ცვლილებებს; ახლის სახით იმპორტი ჯერ ძველ ჩანაწერს შლის.", + "Restore them": "მათი აღდგენა", + "Retouch": "რეტუში", + "Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them": "Return აჭერს OK-ს დიალოგებში „კოპირებისთვის რედაქტირების პარამეტრების არჩევა“ (⇧⌘C) და „მონიშნული პარამეტრების ჩასმა“ (⇧⌘V), Esc კი აუქმებს მათ", + "Reverse the order": "რიგის შებრუნება", + "Revert": "წინა ვერსიაზე დაბრუნება", + "Review": "განხილვა", + "Review Import": "იმპორტის გადახედვა", + "Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename": "მარჯვენა დაწკაპუნებით ფოტოების ძიებაზე, სლაიდერის აკრეფილ მნიშვნელობაზე, ინფორმაციის პანელის ველებზე, ახალი ალბომის, ალბომის", + "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → ფერადი იარლიყის დაყენება → ფერი ან არცერთი. სტრიქონი", + "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → საქაღალდის", + "Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…": "მარჯვენა დაწკაპუნება საქაღალდეზე → ახალი → ალბომის შექმნა… / სმარტ-ალბომის შექმნა… / სმარტ-ალბომის შექმნა ფილტრიდან… / საქაღალდის შექმნა…", + "Right-click a photo ▸ Edit In ▸ Edit in PhotoCraft (or Photo ▸ Edit In, ⇧⌘E) renders a copy with your edits, stacks it": "მარჯვენა დაწკაპუნება ფოტოზე → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (ან ფოტო → რედაქტირება სხვა პროგრამაში, ⇧⌘E) არენდერებს ასლს თქვენი ცვლილებებით, ათავსებს მას", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "დაწკაპეთ ფოტოზე მარჯვენა ღილაკით → ეტალონურ ფოტოდ დაყენება, შემდეგ ხედი → ეტალონური ხედი (Shift+R)", + "Right-click a preset to delete it": "პრესეტის წასაშლელად დაწკაპეთ მასზე მარჯვენა ღილაკით", + "Right-click ▸ Put New Keywords Inside This Keyword: new keywords, made with + or typed in the box above, go inside": "მარჯვენა დაწკაპუნება → ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა: + ღილაკით ან ზემოთ ველში აკრეფილი ახალი საკვანძო სიტყვები", + "Rights Usage Terms": "უფლებების გამოყენების პირობები", + "Rotate": "შემობრუნება", + "Rotate Crop": "ჩამოჭრის შემობრუნება", + "Rotate Crop Aspect": "ჩამოჭრის თანაფარდობის შემობრუნება", + "Rotate Left": "მარცხნივ შემობრუნება", + "Rotate Right": "მარჯვნივ შემობრუნება", + "Roughness": "უხეშობა", + "Roundness": "სიმრგვალე", + "Rule problems are shown in your language and name the field the way the menu does (\"Title: needs something to look": "წესების პრობლემები ნაჩვენებია თქვენს ენაზე და ველს ისე ასახელებს, როგორც მენიუ („დასახელება: საჭიროა საძიებო", + "Rule values are checked: a rating of 9, a date like 2026-13 or \"banana\", a \"between\" whose dates are the wrong": "წესების მნიშვნელობები მოწმდება: შეფასება 9, თარიღი, როგორიცაა 2026-13 ან „banana“, „შორის“, რომლის თარიღებიც არასწორი", + "Rules": "წესები", + "SAM 3 download stopped: it resumes where it left off next time.": "SAM 3-ის ჩამოტვირთვა შეწყდა: შემდეგ ჯერზე იქიდან გაგრძელდება, სადაც შეჩერდა.", + "SDR": "SDR", + "SDR Brightness": "SDR სიკაშკაშე", + "SDR Clarity": "SDR სიცხადე", + "SDR Contrast": "SDR კონტრასტი", + "SDR Highlights": "SDR ღია ტონები", + "SDR Shadows": "SDR ჩრდილები", + "SDR Whites": "SDR თეთრები", + "Sa": "შაბ", + "Same as raw default": "როგორც RAW-ის ნაგულისხმევ პარამეტრებში", + "Sample Mask Color": "ნიღბის ფერის ნიმუშის აღება", + "Sample a colour on the photo": "ფოტოზე ფერის ნიმუშის აღება", + "Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1": "Samsung SRW ფაილები შეკუმშვის გარეშე ახლა RAW-ებად იხსნება: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1", + "Saturation": "გაჯერება", + "Saturation Range": "გაჯერების დიაპაზონი", + "Saturday": "შაბათი", + "Save": "შენახვა", + "Save As…": "შენახვა როგორც…", + "Save Color Label Set": "ფერადი იარლიყების ნაკრების შენახვა", + "Save Current Filter as Preset…": "მიმდინარე ფილტრის პრესეტად შენახვა…", + "Save Current Keywords as Set…": "მიმდინარე საკვანძო სიტყვების ნაკრებად შენახვა…", + "Save Export Preset": "ექსპორტის პრესეტის შენახვა", + "Save Filter Preset": "ფილტრის პრესეტის შენახვა", + "Save Keyword Set": "საკვანძო სიტყვების ნაკრების შენახვა", + "Save Metadata Preset": "მეტამონაცემების პრესეტის შენახვა", + "Save Metadata Preset…": "მეტამონაცემების პრესეტის შენახვა…", + "Save Metadata to File": "მეტამონაცემების ფაილში შენახვა", + "Save Point Curve Preset": "წერტილოვანი მრუდის პრესეტის შენახვა", + "Save Point Curve…": "წერტილოვანი მრუდის შენახვა…", + "Save as Preset": "პრესეტად შენახვა", + "Save as Set…": "ნაკრებად შენახვა…", + "Save as Smart Album…": "სმარტ-ალბომად შენახვა…", + "Save as a new set": "ახალ ნაკრებად შენახვა", + "Save as set": "ნაკრებად შენახვა", + "Save as…": "შენახვა როგორც…", + "Save preset": "პრესეტის შენახვა", + "Saving Lightroom import index…": "Lightroom-ის იმპორტის ინდექსის შენახვა…", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "სხვა აპლიკაციის მიერ ჩაწერილ XMP თანმხლებ ფაილში მეტამონაცემების შენახვა მას აღარ ანაცვლებს (ხარვეზი #92): LightCraft აერთიანებს თავის", + "Scale": "მასშტაბი", + "Scan Folder for Changes": "საქაღალდის ცვლილებების სკანირება", + "Scan failed": "სკანირება ვერ მოხერხდა", + "Scanning": "სკანირება", + "Scanning folder": "საქაღალდის სკანირება", + "Scanning…": "სკანირება…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "ქულები ფოტოებზე რჩება: გაფილტრეთ ან შექმენით სმარტ-ალბომები ფოკუსისა და მსგავს კადრებს შორის საუკეთესოს მიხედვით.", + "Screen": "ეკრანი", + "Scroll menu down": "მენიუს ქვემოთ გადახვევა", + "Scroll menu up": "მენიუს ზემოთ გადახვევა", + "Search": "ძიება", + "Search Photos": "ფოტოების ძიება", + "Search albums": "ალბომების ძიება", + "Search commands or keys": "ბრძანებების ან კლავიშების ძიება", + "Search…": "ძიება…", + "Seasons": "სეზონები", + "Second Window": "მეორე ფანჯარა", + "Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows'": "სექციები ერთმანეთისგან უკეთ გაირჩევა: მათ შორის თხელი ხაზია, ჩაკეცვის ისარი სტრიქონების", + "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "იხ. [დისტანციური Metal ინფერენსი](ai-masks.md#remote-metal-inference).", + "Select": "მონიშვნა", + "Select All": "ყველაფრის მონიშვნა", + "Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one": "ყველაფრის მონიშვნა და მრავლობითი მონიშვნა ბადესა და კადრების ზოლში ყველა მონიშნულ ფოტოს აჩვენებს და არა მხოლოდ აქტიურს", + "Select Luminance Range": "სიკაშკაშის დიაპაზონის არჩევა", + "Select Mask": "ნიღბის არჩევა", + "Select Photos": "ფოტოების მონიშვნა", + "Select Spot": "ლაქის არჩევა", + "Select a photo to edit": "აირჩიეთ ფოტო რედაქტირებისთვის", + "Select by": "მონიშვნა:", + "Select by Flag, Rating or Label": "მონიშვნა დროშის, შეფასების ან იარლიყის მიხედვით", + "Select photos to give them keywords.": "მონიშნეთ ფოტოები, რომ მათ საკვანძო სიტყვები მიანიჭოთ.", + "Select photos, then choose View → Survey (N)": "მონიშნეთ ფოტოები, შემდეგ აირჩიეთ ხედი → მიმოხილვა (N)", + "Select two photos, then choose View → Compare (Shift+C)": "მონიშნეთ ორი ფოტო, შემდეგ აირჩიეთ ხედი → შედარება (Shift+C)", + "Selected": "მონიშნული", + "Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit": "პარამეტრების ჩასმა). Ctrl+V LightCraft-ს მხოლოდ მაშინ აღწევს, როცა სისტემის ბუფერში ტექსტია; სხვა შემთხვევაში გამოიყენეთ რედაქტირება → რედაქტირების", + "Selecting…": "მონიშვნა…", + "Selenium Tone": "სელენის ტონირება", + "Send Feedback…": "გამოხმაურების გაგზავნა…", + "Separated by commas": "მძიმით გამოყოფილი", + "Sepia Tone": "სეპიის ტონი", + "Sequence number zero-padded to N digits (1–9)": "რიგითი ნომერი, ნულებით შევსებული N ციფრამდე (1–9)", + "Sequence number, counting from the start number": "რიგითი ნომერი, საწყისი რიცხვიდან დათვლით", + "Services": "სერვისები", + "Set": "დაყენება", + "Set Album Cover": "ალბომის ყდის დაყენება", + "Set Before": "„მანამდე“-ს დაყენება", + "Set Color Label": "ფერადი იარლიყის დაყენება", + "Set Crop": "ჩამოჭრის დაყენება", + "Set Develop Values": "დამუშავების მნიშვნელობების დაყენება", + "Set Flag": "დროშის დაყენება", + "Set Folder Color Label": "საქაღალდის ფერადი იარლიყის მინიჭება", + "Set Image Zoom and Pan": "გამოსახულების მასშტაბისა და გადაადგილების დაყენება", + "Set Keyboard Shortcut": "კლავიატურის მალსახმობის მინიჭება", + "Set Mask Adjustments": "ნიღბის კორექციების დაყენება", + "Set Point Curve": "წერტილოვანი მრუდის დაყენება", + "Set Profile": "პროფილის დაყენება", + "Set Rating": "შეფასების დაყენება", + "Set Top of Stack": "დასტის ზედა ფოტოს დაყენება", + "Set as Album Cover": "ალბომის ყდად დაყენება", + "Set as Before": "„მანამდე“-დ დაყენება", + "Set as Reference Photo": "ეტალონურ ფოტოდ დაყენება", + "Set as Target Album": "სამიზნე ალბომად დაყენება", + "Set as Target Album (B adds to it)": "სამიზნე ალბომად დაყენება (B მასში ამატებს)", + "Set as Top of Stack": "დასტის ზედა ფოტოდ დაყენება", + "Set date & time": "თარიღისა და დროის დაყენება", + "Set name": "ნაკრების სახელი", + "Set the mix from the photo's colours": "მიქსის დაყენება ფოტოს ფერების მიხედვით", + "Set up AI Denoise…": "AI ხმაურის მოცილების გამართვა…", + "Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too.": "ფერადი იარლიყის დაყენება ან მოხსნა ქვემოთ მოკლე დადასტურებას აჩვენებს, როგორც ფოტოს შეფასება; შეტყობინებაში იარლიყების მორგებული სახელებიც ჩანს.", + "Settings": "პარამეტრები", + "Settings file problem": "პარამეტრების ფაილის პრობლემა", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "პარამეტრების ფაილები აღარასოდეს ჩამოიყრება შეუმჩნევლად (ხარვეზი #103): დაზიანებული `prefs.json`, `presets.json` ან `view.json` ინახება", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "პარამეტრები, რომლებითაც ახალი RAW ფოტოები იწყება. მათი შეცვლა ბიბლიოთეკაში უკვე არსებულ ფოტოებს არ ეხება.", + "Settings pasted": "პარამეტრები ჩასმულია", + "Settings to include": "ჩასართავი პარამეტრები", + "Settings ▸ Display (issue #787). On P3 screens previews used to look more saturated than the exported files in": "პარამეტრები → ეკრანში (ხარვეზი #787). P3 ეკრანებზე წინასწარი ხედები ადრე უფრო გაჯერებული ჩანდა, ვიდრე ექსპორტირებული ფაილები", + "Settings ▸ Display ▸ Choose Profile… shows photos through your monitor's ICC profile (matrix/TRC or LUT-based), so": "პარამეტრები → ეკრანი → პროფილის არჩევა… ფოტოებს თქვენი მონიტორის ICC პროფილით აჩვენებს (მატრიცა/TRC ან LUT), ასე რომ", + "Settings ▸ General ▸ External Editing: the format (PSD or TIFF), colour space and bit depth for PhotoCraft and for": "პარამეტრები → ზოგადი → გარე რედაქტირება: ფორმატი (PSD ან TIFF), ფერთა სივრცე და ბიტური სიღრმე PhotoCraft-ისა და", + "Settings ▸ Interface ▸ Appearance Mode: Sync with System, Dark Mode or Light Mode, above a light": "პარამეტრები → ინტერფეისი → იერსახის რეჟიმი: სისტემის მიხედვით, ბნელი რეჟიმი ან ღია რეჟიმი, ღია", + "Settings.": "პარამეტრების ჩასმა.", + "Settings…": "პარამეტრები…", + "Shade": "ჩრდილი", + "Shadow": "ჩრდილი", + "Shadows": "ჩრდილები", + "Shadows Hue": "ჩრდილების ფერის ტონი", + "Shadows Luminance": "ჩრდილების სიკაშკაშე", + "Shadows Saturation": "ჩრდილების გაჯერება", + "Shadows Tint": "ჩრდილების ტონირება", + "Shadows split": "ჩრდილების გაყოფა", + "Sharpen": "გამკვეთრება", + "Sharpening": "გამკვეთრება", + "Sharpness": "სიმკვეთრე", + "Shift": "Shift", + "Shift by": "წანაცვლება:", + "Short Edge": "მოკლე გვერდი", + "Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და", + "Show": "ჩვენება", + "Show All": "ყველას ჩვენება", + "Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება", + "Show Clipping": "ჩამოჭრილი ტონების ჩვენება", + "Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება", + "Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება", + "Show Luminance Map": "სიკაშკაშის რუკის ჩვენება", + "Show Mask": "ნიღბის ჩვენება", + "Show Mask Overlay": "ნიღბის ზედდების ჩვენება", + "Show Mask Pins": "ნიღბის მარკერების ჩვენება", + "Show Original": "ორიგინალის ჩვენება", + "Show Person": "ადამიანის ჩვენება", + "Show Photo Counts": "ფოტოების რაოდენობის ჩვენება", + "Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება", + "Show Pins": "მარკერების ჩვენება", + "Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება", + "Show Source": "წყაროს ჩვენება", + "Show Thumbnails": "ესკიზების ჩვენება", + "Show all overlays": "ყველა ზედდების ჩვენება", + "Show commands without a shortcut": "მალსახმობის არმქონე ბრძანებების ჩვენება", + "Show destination gamut warning": "დანიშნულების ფერთა გამიდან გასვლის გაფრთხილების ჩვენება", + "Show display gamut warning": "ეკრანის ფერთა გამიდან გასვლის გაფრთხილების ჩვენება", + "Show file names": "ფაილების სახელების ჩვენება", + "Show in Compare": "შედარებაში ჩვენება", + "Show in Explorer": "Explorer-ში ჩვენება", + "Show in File Manager": "ფაილების მენეჯერში ჩვენება", + "Show in Finder": "Finder-ში ჩვენება", + "Show less": "ნაკლების ჩვენება", + "Show on Map": "რუკაზე ჩვენება", + "Show overlay (O)": "ზედდების ჩვენება (O)", + "Show ratings, flags and edit badges": "შეფასებების, დროშებისა და ცვლილების ნიშნების ჩვენება", + "Show the Navigator while zoomed in": "ნავიგატორის ჩვენება გადიდებისას", + "Show the template tags; click one to insert it": "შაბლონის ტეგების ჩვენება; ჩასასმელად დაწკაპეთ ერთ-ერთზე", + "Show this wheel's effect": "ამ ბორბლის ეფექტის ჩვენება", + "Show/Hide Mask": "ნიღბის ჩვენება/დამალვა", + "Show/hide My Photos panel": "„ჩემი ფოტოების“ პანელის ჩვენება/დამალვა", + "Shutter Speed": "ჩამკეტის სიჩქარე", + "Shutter speed": "ჩამკეტის სიჩქარე", + "Side panels": "გვერდითი პანელები", + "Sidebar Sections": "გვერდითი ზოლის სექციები", + "Sidebar sections": "გვერდითი ზოლის სექციები", + "Sidecar names": "თანმხლები ფაილების სახელები", + "Silver": "ვერცხლისფერი", + "Simulate Paper & Ink": "ქაღალდისა და მელნის იმიტაცია", + "Size": "ზომა", + "Size (% of short edge)": "ზომა (მოკლე გვერდის %)", + "Size limit": "ზომის ზღვარი", + "Size rules measure the photo as shown: Megapixels now counts the cropped photo, like Long Edge, Short Edge and": "ზომის წესები ფოტოს ზომავს ისე, როგორც ჩანს: მეგაპიქსელი ახლა ჩამოჭრილ ფოტოს ითვლის, როგორც გრძელი გვერდი, მოკლე გვერდი და", + "Skip": "გამოტოვება", + "Skipped: files already in the library elsewhere": "გამოტოვებულია: ფაილები, რომლებიც ბიბლიოთეკაში უკვე სხვა ადგილასაა", + "Skipped: files that can't be read": "გამოტოვებულია: ფაილები, რომელთა წაკითხვა შეუძლებელია", + "Sky": "ცა", + "Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it": "ცის, საგნის და ფონის მონიშვნის ნიღბებისთვის ჩვენი ევრისტიკის ნაცვლად, ასე რომ ცა იქ მოინიშნება, სადაც კამერამ იპოვა", + "Slider drags follow the pointer more closely": "სლაიდერის გადათრევა მაჩვენებელს უფრო ზუსტად მისდევს", + "Slider drags on large raws": "სლაიდერის გადათრევა დიდ RAW ფაილებზე", + "Slideshow": "სლაიდშოუ", + "Slideshow paused": "სლაიდშოუ შეჩერებულია", + "Slideshow resumed": "სლაიდშოუ განახლდა", + "Slideshow · Space pauses · Esc ends": "სლაიდშოუ · Space აჩერებს · Esc ასრულებს", + "Slideshow, second window, All Metadata, System Info.": "სლაიდშოუ, მეორე ფანჯარა, ყველა მეტამონაცემი, სისტემის ინფორმაცია.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "უფრო მცირე და სწრაფი კატალოგები: ფოტოები, რომლებიც ლოკალურში მხოლოდ დაათვალიერეთ (არასოდეს დაგიმატებიათ, შეგიფასებიათ ან დაგიმუშავებიათ), დავიწყებას ეძლევა, როცა", + "Smart Album": "სმარტ-ალბომი", + "Smart Album Rule Fields": "სმარტ-ალბომის წესის ველები", + "Smart Preview Status": "ჭკვიანი წინასწარი ხედის სტატუსი", + "Smart Previews Location": "ჭკვიანი წინასწარი ხედების მდებარეობა", + "Smart album rules": "სმარტ-ალბომის წესები", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "სმარტ-ალბომები წესების რედაქტორით: ყველა / რომელიმე / არცერთი დამთხვევა, ჩადგმული ჯგუფები, 26 ველი.", + "Smart preview available": "ჭკვიანი წინასწარი ხედი ხელმისაწვდომია", + "Smart previews": "ჭკვიანი წინასწარი ხედები", + "Smart previews folder changed": "ჭკვიანი წინასწარი ხედების საქაღალდე შეიცვალა", + "Smoothness": "სიგლუვე", + "Soft B&W": "რბილი შავ-თეთრი", + "Soft Pastel": "რბილი პასტელი", + "Soft Proofing": "საცდელი ჩვენება", + "Soft Skin": "რბილი კანი", + "Some rules need fixing (Edit Smart Album…):": "ზოგიერთ წესს გასწორება სჭირდება (სმარტ-ალბომის რედაქტირება…):", + "Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the": "Sony A7R II-ის (და სხვა) RAW-ები, რომელთა კამერის JPEG ობიექტივისთვის კორექტირებულია, აღარ იხსნება ნაცრისფრად და ზედმეტად მუქად (ხარვეზი #232):", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony-ის ARW-ები დაახლოებით 2017 წლამდე (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II და მათი", + "Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too,": "Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 და ILCE-7S კამერების RAW ფაილებიც ახლა თეთრის ბალანსს კელვინებში აჩვენებს,", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4-ის შემცირებული ზომის უდანაკარგო ARW-ები ახლა დისკრეტიზაციაშემცირებულ YCbCr ფილებს წრფივ RGB-ში დეკოდირებს,", + "Sort": "სორტირება", + "Sort Albums A–Z": "ალბომების დალაგება ა–ჰ", + "Sort Albums by Name": "ალბომების დალაგება სახელის მიხედვით", + "Sort Contents A–Z": "შიგთავსის დალაგება ა–ჰ", + "Sort Contents A–Z (or + ▸ Sort Albums A–Z at the top level) goes back to by name. To put a folder after an open": "შიგთავსის სორტირება ა–ჰ (ან ზედა დონეზე + → ალბომების სორტირება ა–ჰ) სახელით დალაგებას აბრუნებს. საქაღალდის ღია", + "Sort by": "სორტირება:", + "Source": "წყარო", + "Source code on GitHub": "საწყისი კოდი GitHub-ზე", + "Speed": "სიჩქარე", + "Spherical": "სფერული", + "Spiral": "სპირალი", + "Split Stack": "დასტის გაყოფა", + "Spring Fresh": "გაზაფხულის სიახლე", + "Square": "კვადრატული", + "Square Grid": "კვადრატული ბადე", + "Square Grid (G toggles)": "კვადრატული ბადე (G გადართავს)", + "Square Grid: show file names and formats": "კვადრატული ბადე: ფაილების სახელებისა და ფორმატების ჩვენება", + "Stack": "დასტა", + "Stack photos taken within this time of each other:": "დასტად დაჯგუფდეს ფოტოები, რომლებიც ერთმანეთისგან ამ დროის ფარგლებში გადაიღეს:", + "Stacks": "დასტები", + "Standard": "სტანდარტული", + "Star rating (0–5)": "ვარსკვლავებით შეფასება (0–5)", + "Start at": "დაწყება:", + "Start number": "საწყისი რიცხვი", + "Starting": "იწყება", + "Starting…": "იწყება…", + "Startup": "გაშვება", + "State / Province": "შტატი / პროვინცია", + "Stop": "შეწყვეტა", + "Stop Building Previews": "წინასწარი ხედების შექმნის შეწყვეტა", + "Stop Using as Target Album": "სამიზნე ალბომად გამოყენების შეწყვეტა", + "Stopped": "შეწყდა", + "Stopped by an error (see the log)": "შეწყდა შეცდომის გამო (იხილეთ ჟურნალი)", + "Stopping…": "ჩერდება…", + "Straighten": "გასწორება", + "Straighten Tool": "გასწორების ხელსაწყო", + "Strong Contrast": "ძლიერი კონტრასტი", + "Style": "სტილი", + "Su": "კვი", + "Subfolder": "ქვესაქაღალდე", + "Subfolder name": "ქვესაქაღალდის სახელი", + "Subject": "საგანი", + "Subtract": "გამოკლება", + "Subtract from this mask": "ამ ნიღბიდან გამოკლება", + "Suggest Names for Faces": "სახეებისთვის სახელების შეთავაზება", + "Suggestions": "შეთავაზებები", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "გვთავაზობს, ვინ არის ფოტოზე, თქვენ მიერ დასახელებული სახეების მიხედვით. ყველაფერი თქვენს კომპიუტერზე რჩება.", + "Summer Haze": "ზაფხულის ბურუსი", + "Sunday": "კვირა", + "Survey": "მიმოხილვა", + "Survey (N)": "მიმოხილვა (N)", + "Swap": "გაცვლა", + "Swap Aspect (X)": "თანაფარდობის გაცვლა (X)", + "Swap Before and After": "„მანამდე“-სა და „შემდეგ“-ის გაცვლა", + "Swap Before and After Settings": "„მანამდე“-სა და „შემდეგ“-ის პარამეტრების გაცვლა", + "Swap Compare Photos": "შესადარებელი ფოტოების გაცვლა", + "Switch light / dark theme": "ღია / ბნელი თემის გადართვა", + "Switch to & Containing to see the keywords with the keywords containing them, or to Will Export to see what": "გადართეთ „და შემცველი“-ზე, რომ საკვანძო სიტყვები მათი შემცველი საკვანძო სიტყვებით ნახოთ, ან „ექსპორტირდება“-ზე, რომ ნახოთ, რას", + "Switching libraries": "ბიბლიოთეკების გადართვა", + "Sync Settings": "პარამეტრების სინქრონიზაცია", + "Sync with System": "სისტემასთან სინქრონიზაცია", + "Synchronize": "სინქრონიზაცია", + "Synchronize Folder": "საქაღალდის სინქრონიზაცია", + "Synchronize Folder…": "საქაღალდის სინქრონიზაცია…", + "Synchronizing folder": "საქაღალდის სინქრონიზაცია", + "Synchronizing…": "სინქრონიზაცია…", + "Synonyms": "სინონიმები", + "Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`, and keywords": "სინონიმების ექსპორტი. ექსპორტი ახლა საკვანძო სიტყვების სახელებს `dc:subject`-ში წერს, მათ გზებს კი `lr:hierarchicalSubject`-ში, ხოლო", + "System": "სისტემა", + "System Info": "სისტემის ინფორმაცია", + "System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK": "სისტემის ინფორმაცია, ყველა მეტამონაცემი და სიახლეები სრულდება ერთი „დახურვის“ ღილაკით, როგორც „პროგრამის შესახებ“, „გაუქმებისა“ და OK-ის ნაცვლად", + "System Info…": "სისტემის ინფორმაცია…", + "System default": "სისტემის ნაგულისხმევი", + "T) now matches.": "T-ის ნაცვლად) ახლა ემთხვევა.", + "TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode": "TIFF-ები), აღარ იხსნება ესკიზის ზომის ჩვეულებრივ გამოსახულებად. ისინი ამოიცნობა როგორც RAW-ები, რომელთა დეკოდირება LightCraft-ს", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "TZ/ZS სერია, Leica D-Lux, V-Lux და C-Lux, ასევე GH6, GH7, G9 II, S5 II და S9 თაობა. ისინი იწყება", + "Table": "ცხრილი", + "Tags": "ტეგები", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "ტეგები: იხილეთ „ტეგები“ შაბლონის გვერდით. ფაილებს სახელი დისკზე ეცვლება (მათ XMP თანმხლებ ფაილებთან ერთად); არსებულ სახელებს ემატება -1, -2…", + "Take a snapshot": "მომენტალური ასლის შექმნა", + "Targeted Adjustment": "მიზნობრივი კორექცია", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "მიზნობრივი კორექცია ჩართულია: გადაათრიეთ ფოტოზე ზემოთ ან ქვემოთ · Esc — შეწყვეტა", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "მიზნობრივი კორექცია: გადაათრიეთ ფოტოზე ზემოთ/ქვემოთ, რომ იქ ფერები შეცვალოთ", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "მიზნობრივი კორექცია: გადაათრიეთ ფოტოზე ზემოთ/ქვემოთ, რომ იქ მრუდი შეცვალოთ", + "Teal & Amber": "ზღვისფერი და ქარვისფერი", + "Teal & Orange": "ზღვისფერი და ნარინჯისფერი", + "Temp": "ტემპ.", + "Temperature": "ტემპერატურა", + "Template": "შაბლონი", + "Temporary session — your library isn't open, and nothing you do here is saved.": "დროებითი სესია — თქვენი ბიბლიოთეკა გახსნილი არ არის და აქ არაფერი ინახება.", + "Test Denoise Model": "ხმაურის მოცილების მოდელის შემოწმება", + "Test Face Model": "სახეების მოდელის შემოწმება", + "Tested and works on this computer": "შემოწმებულია და მუშაობს ამ კომპიუტერზე", + "Text": "ტექსტი", + "Text direction": "ტექსტის მიმართულება", + "Text fields": "ტექსტური ველები", + "Texture": "ტექსტურა", + "Th": "ხუთ", + "That includes the photos in the folders inside it.": "მასში შემავალ საქაღალდეებში არსებული ფოტოების ჩათვლით.", + "That key can't be used as a shortcut": "ეს კლავიში მალსახმობად ვერ გამოიყენება", + "The Export dialog has **Add to Apple Photos**, with an optional album. The files are exported to your folder as": "ექსპორტის დიალოგს აქვს **Apple Photos-ში დამატება**, არასავალდებულო ალბომით. ფაილები ჩვეულებრივად ექსპორტირდება თქვენს საქაღალდეში", + "The Hasselblad Lunar's raws (a Sony NEX-7 inside) now develop from their sensor data with every editing tool,": "Hasselblad Lunar-ის (შიგნით Sony NEX-7) RAW ფაილები ახლა სენსორის მონაცემებიდან მუშავდება ყველა რედაქტირების ხელსაწყოთი,", + "The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks": "ფოტოების იმპორტის მიმოხილვა უფრო დიდი იხსნება და მისი ზომის შეცვლა შეიძლება; ფოტოების ბადე მას ავსებს (ხარვეზი #337). Shift-დაწკაპუნება", + "The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no": "საკვანძო სიტყვების პანელი საკვანძო სიტყვების სიით სრულდება, როგორც Lightroom Classic-ში: ყველა საკვანძო სიტყვა ფოტოების რაოდენობით, მათ შორის ისეთებიც,", + "The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the": "პრესეტების პანელი აგებს მხოლოდ ხილულ რიგებს (და მათ ესკიზებს) ყოველ კადრზე ყველა პრესეტის ნაცვლად;", + "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 მოდელი დაინსტალირებულია: „ობიექტის“ და „აღწერის“ ნიღბები მზადაა.", + "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "ტონირების სლაიდერი სწორი მიმართულებით მუშაობს (ხარვეზები #188, #321): მარცხნივ მწვანეს ამატებს, მარჯვნივ მეწამულს, როგორც მისი ზოლი", + "The Windows app no longer quits at launch with \"Parent device is lost\" when another program's `dxcompiler.dll` is": "Windows-ის აპი გაშვებისას აღარ ითიშება შეტყობინებით „Parent device is lost“, როცა სხვა პროგრამის `dxcompiler.dll`", + "The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you": "Windows-ის ინსტალატორი კითხულობს, სად დაყენდეს LightCraft (ნაგულისხმევად Program Files; განახლებები თქვენ მიერ არჩეულ", + "The catalog format is now version 3 (`Album.order`, `Op::SetAlbumOrder`). Once this version has opened a library,": "კატალოგის ფორმატი ახლა მე-3 ვერსიაა (`Album.order`, `Op::SetAlbumOrder`). როგორც კი ეს ვერსია ბიბლიოთეკას გახსნის,", + "The catalog format is now version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Once this version has": "კატალოგის ფორმატი ახლა მე-4 ვერსიაა (`Catalog.folder_records`, `Op::SetFolderRecord`). როგორც კი ეს ვერსია", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "კატალოგს აქვს ფორმატის ვერსია (ხარვეზი #102). ძველი LightCraft-ის ბიბლიოთეკის გახსნა მას განაახლებს; ბიბლიოთეკა", + "The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a": "საკვანძო სიტყვების პანელის თავში ჩიპები ყველა მონიშნული ფოტოს საკვანძო სიტყვებია და არა მხოლოდ აქტიურისა;", + "The colour is described by the PNG `cICP` chunk, not an ICC profile. HDR-aware viewers (current Chromium-based": "ფერი აღწერილია PNG-ის `cICP` ნაწილით და არა ICC პროფილით. HDR-ის მცოდნე მაყურებლები (მაგ., მიმდინარე Chromium-ზე დაფუძნებული", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "მართვის პორტი (`--control`) კავშირს ხურავს, როგორც კი მიიღებს რამეს, რაც JSON მოთხოვნა არ არის", + "The copy is a 16-bit PSD by default, which PhotoCraft saves straight back to with File ▸ Save; when you come back to": "ასლი ნაგულისხმევად 16-ბიტიანი PSD-ია, რომელშიც PhotoCraft პირდაპირ ინახავს ბრძანებით ფაილი → შენახვა; როცა დაბრუნდებით", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "ხმაურის მოცილების მოდელი დაინსტალირებული და გამოყენებაშია: AI ხმაურის მოცილების სიძლიერის სლაიდერი „დეტალში“ ახლა მუშაობს", + "The desktop app keeps a log file: `logs/lightcraft.log` in its settings folder (Linux `~/.config/lightcraft/logs/`),": "სამუშაო მაგიდის აპი ჟურნალის ფაილს ინახავს: `logs/lightcraft.log` თავის პარამეტრების საქაღალდეში (Linux `~/.config/lightcraft/logs/`),", + "The download didn't work:": "ჩამოტვირთვა ვერ მოხერხდა:", + "The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,": "სინათლის, ფერის და დეტალის სექციების სათაურებზე თვალი ახლა მათ კორექციებს მალავს, როგორც უკვე ხდებოდა ეფექტების,", + "The first time, macOS asks whether LightCraft may control Photos.": "პირველად macOS იკითხავს, შეუძლია თუ არა LightCraft-ს Photos-ის მართვა.", + "The first time, macOS asks whether LightCraft may control Photos. Photos may ask about duplicates; files it skips": "პირველად macOS იკითხავს, შეუძლია თუ არა LightCraft-ს Photos-ის მართვა. Photos-მა შეიძლება დუბლიკატებზე იკითხოს; ფაილები, რომლებსაც გამოტოვებს,", + "The graphics card is set up when the next photo is made.": "ვიდეობარათი გაიმართება, როცა შემდეგი ფოტო დამუშავდება.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "იმპორტი კატალოგს კითხულობს მისი შეცვლის გარეშე და მის ფოტოებს, ცვლილებებს, საკვანძო სიტყვებსა და კოლექციებს ახლა გახსნილ ბიბლიოთეკას ამატებს.", + "The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,": "ინტერფეისი ხელმისაწვდომია ესპანურად (ხარვეზი #371), გერმანულად და რუსულად (რედაქტირება → ენა), ინგლისურის,", + "The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share": "შენახული ორიგინალი აღარ აძევებს ქეშიდან ყველაფერ დანარჩენს: 64 MP RAW-ზე ის ქეშის წილს", + "The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste": "საკვანძო სიტყვების ნაკრების ღილაკები და შეთავაზებებიც მონიშნულს მიჰყვება; ასაკრეფ ველებს აქვს ამოჭრა / კოპირება / ჩასმის", + "The library is up to date with this folder.": "ბიბლიოთეკა ამ საქაღალდესთან სინქრონიზებულია.", + "The loupe says \"Rendering…\" at the bottom of the photo when what it shows isn't the finished photo yet: the": "ლუპა ფოტოს ქვედა ნაწილში წერს „რენდერდება…“, როცა ის, რასაც აჩვენებს, ჯერ დასრულებული ფოტო არ არის:", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "მოდელი მხოლოდ ამ კომპიუტერზე ინახება. LightCraft მას არასოდეს ატვირთავს და არ აზიარებს.", + "The number at the end of the original name (IMG_0042 → 0042)": "რიცხვი ორიგინალი სახელის ბოლოს (IMG_0042 → 0042)", + "The open library's name shows under My Photos at the top of the left panel and in the window title.": "გახსნილი ბიბლიოთეკის სახელი ჩანს „ჩემი ფოტოების“ ქვეშ, მარცხენა პანელის ზედა ნაწილში, და ფანჯრის სათაურში.", + "The original extension is always added; {ext} only puts it inside the name as well.": "ორიგინალი გაფართოება ყოველთვის ემატება; {ext} მას დამატებით სახელშიც სვამს.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "ორიგინალი ფაილები უცვლელად, თითოეული XMP თანმხლები ფაილით, რომელიც მის ცვლილებებს ინახავს.", + "The photo has these settings": "ფოტოს ეს პარამეტრები აქვს", + "The processor does the work.": "სამუშაოს პროცესორი ასრულებს.", + "The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An": "წესების რედაქტორი თითოეულ გასასწორებელ წესს უშუალოდ მის ქვეშ აღნიშნავს და OK-ს გამორთულს ტოვებს, სანამ არ გასწორდება.", + "The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then": "წესების რედაქტორის ველების მენიუ დაჯგუფებულია: შეფასება, რჩეულის დროშა, ფერადი იარლიყი და ნებისმიერი საძიებო ტექსტი თავში, შემდეგ", + "The starting look fitted from a raw's camera JPEG now uses its darkest tones too (issue #1307).": "RAW-ის კამერის JPEG-იდან მორგებული საწყისი იერი ახლა მის ყველაზე მუქ ტონებსაც იყენებს (ხარვეზი #1307).", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "ესკიზების ქეში მხოლოდ საკუთარ ფაილებს ითვლის და შლის (ხარვეზი #98): საქაღალდეზე გახსნილი ბიბლიოთეკა, რომელიც", + "The top bar's appearance button cycles Auto, Light, Dark (a monitor, sun or moon), and View ▸": "ზედა ზოლის იერსახის ღილაკი მონაცვლეობს: ავტომატური, ღია, ბნელი (მონიტორი, მზე ან მთვარე), ხედი →", + "Their photos and the keywords below them come together under one keyword.": "მათი ფოტოები და მათ ქვეშ მყოფი საკვანძო სიტყვები ერთი საკვანძო სიტყვის ქვეშ ერთიანდება.", + "Theme": "თემა", + "There is no model file to install": "დასაინსტალირებელი მოდელის ფაილი არ არის", + "They can be restored from Recently Deleted until it is emptied.": "მათი აღდგენა შესაძლებელია „ახლახან წაშლილიდან“, სანამ ის არ დაცარიელდება.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "ისინი „ახლახან წაშლილში“ გადადის და შეიძლება აღდგეს; დისკზე არცერთ ფაილს არ ეხება.", + "Thirds": "მესამედები", + "This Computer": "ეს კომპიუტერი", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "ამ ვერსიას მოდელის ჩამოტვირთვა ჯერ არ შეუძლია. ხელით დასაინსტალირებლად ზემოთ მითითებულ საქაღალდეში მოათავსეთ model.safetensors, vocab.json და merges.txt Meta-ს facebook/sam3-დან: „ობიექტი“ და „აღწერა“ იქ მათი გაჩენისთანავე იმუშავებს.", + "This build cannot run denoise models.": "ამ ვერსიას ხმაურის მოცილების მოდელების გაშვება არ შეუძლია.", + "This build cannot run recognition models: they can be added and chosen, not used.": "ამ ვერსიას ამოცნობის მოდელების გაშვება არ შეუძლია: მათი დამატება და არჩევა შეიძლება, გამოყენება — არა.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "ამ ვერსიას ხმაურის მოცილების მოდელების შესანახი ადგილი არ აქვს, ამიტომ AI ხმაურის მოცილება მიუწვდომელია. (სამაგიდო აპლიკაციას აქვს.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "ამ ვერსიას სახეების მოდელების შესანახი ადგილი არ აქვს, ამიტომ მოდელების სია მიუწვდომელია. (სამაგიდო აპლიკაციას აქვს.)", + "This file cannot be used yet": "ამ ფაილის გამოყენება ჯერ შეუძლებელია", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "ეს საქაღალდე მიუწვდომელია (დისკი გამოერთებულია?). ჭკვიანი წინასწარი ხედები არ იქმნება და არ გამოიყენება, სანამ ის არ დაბრუნდება ან სხვა საქაღალდეს არ აირჩევთ.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "ეს საქაღალდე აქ არ არის: ის გადაიტანეს ან გადაარქვეს, ან მისი დისკი არ არის მიერთებული.", + "This group is empty: add a rule or remove it.": "ეს ჯგუფი ცარიელია: დაამატეთ წესი ან წაშალეთ ის.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "ეს Lightroom-ის კატალოგია და არა LightCraft-ის ბიბლიოთეკა. გსურთ მისი LightCraft-ის ბიბლიოთეკაში იმპორტი?", + "This is the album you're editing": "ეს ის ალბომია, რომელსაც არედაქტირებთ", + "This photo can't be opened": "ამ ფოტოს გახსნა შეუძლებელია", + "Threshold": "ზღვარი", + "Thumbnail cache": "ესკიზების ქეში", + "Thursday": "ხუთშაბათი", + "Tick the box to accept the model's terms first": "ჯერ მონიშნეთ ველი მოდელის პირობების მისაღებად", + "Time zone": "სასაათო სარტყელი", + "Tint": "ტონირება", + "Title": "დასახელება", + "Title metadata": "დასახელების მეტამონაცემები", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "თუ ამ რედაქტორს ასლის ნაცვლად თავად ორიგინალი ფაილი უნდა გადაეცეს (RAW, ასლის გარეშე: მაგალითად, DxO PhotoLab), ფოტოზე მარჯვენა ღილაკით დაწკაპეთ → რედაქტირება სხვა პროგრამაში → ორიგინალის გახსნა სხვა რედაქტორში (RAW, ასლის გარეშე).", + "Toggle Keyword from Set": "ნაკრებიდან საკვანძო სიტყვის გადართვა", + "Toggle Mask": "ნიღბის გადართვა", + "Toggle Section": "სექციის გადართვა", + "Toggle Zoom": "მასშტაბის გადართვა", + "Told when there is no GPU": "შეტყობინება, როცა GPU არ არის", + "Tone Curve": "ტონალური მრუდი", + "Tools": "ხელსაწყოები", + "Top Level": "ზედა დონე", + "Trackpads: pinch to zoom around the pointer and scroll with two fingers to pan the photo; panning keeps the photo": "თაჩპედები: მასშტაბისთვის მაჩვენებლის გარშემო მოჭერით შეკუმშეთ, ფოტოს გადასაადგილებლად კი ორი თითით გადაახვიეთ; გადაადგილებისას ფოტო", + "Transfer": "გადაცემა", + "Treatment": "დამუშავება", + "Triangle": "სამკუთხედი", + "Try Again": "ხელახლა ცდა", + "Try Saving Again": "შენახვის ხელახლა ცდა", + "Try again": "ხელახლა ცდა", + "Try the graphics card again": "ვიდეობარათის ხელახლა ცდა", + "Tu": "სამ", + "Tuesday": "სამშაბათი", + "Tungsten": "ვარვარების ნათურა", + "Turn Off": "გამორთვა", + "Turn off": "გამორთვა", + "Turn on Include subfolders, or pick another folder under Local": "ჩართეთ „ქვესაქაღალდეების ჩათვლით“ ან აირჩიეთ სხვა საქაღალდე ლოკალურში", + "Type": "ტიპი", + "Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)": "აკრიფეთ ზუსტი მნიშვნელობა ნებისმიერ სლაიდერში (ხარვეზი #322): დაწკაპეთ მისი სახელის გვერდით რიცხვზე, აკრიფეთ (`1.5`, `-20`, `5600`)", + "Uncheck All": "ყველას მონიშვნის მოხსნა", + "Uncheck the files of this type": "ამ ტიპის ფაილების მონიშვნის მოხსნა", + "Undo": "დაბრუნება", + "Unedited": "დაუმუშავებელი", + "Unedited raws": "დაუმუშავებელი RAW-ები", + "Unflag": "დროშის მოხსნა", + "Unflagged": "დროშის გარეშე", + "Ungroup Stack": "დასტის დაშლა", + "Unknown": "უცნობი", + "Unknown Date": "უცნობი თარიღი", + "Unknown licence": "უცნობი ლიცენზია", + "Unknown tags stay as typed — check the preview for a {typo}.": "უცნობი ტეგები რჩება ისე, როგორც აკრიფეთ — გადახედვაში შეამოწმეთ {typo}.", + "Unnamed Faces": "უსახელო სახეები", + "Unrated": "შეუფასებელი", + "Unreadable": "წაუკითხავი", + "Update": "განახლება", + "Update Mask Shape": "ნიღბის ფორმის განახლება", + "Update Preset with Current Settings": "პრესეტის განახლება მიმდინარე პარამეტრებით", + "Update Rules from Current Filter": "წესების განახლება მიმდინარე ფილტრიდან", + "Update Version": "ვერსიის განახლება", + "Update Version with Current Settings": "ვერსიის განახლება მიმდინარე პარამეტრებით", + "Update to Current Process": "მიმდინარე პროცესის ვერსიაზე განახლება", + "Update with Current Settings": "განახლება მიმდინარე პარამეტრებით", + "Updated the edits saved in the external editor": "გარე რედაქტორში შენახული ცვლილებები განახლდა", + "Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed": "0.6-ზე ძველი Windows-ის ინსტალერიდან (მაგ., 0.5.0) განახლებისას LightCraft რჩება იმ საქაღალდეში, სადაც", + "Upright": "Upright", + "Urban": "ქალაქური", + "Use": "გამოყენება", + "Use Keyword Set": "საკვანძო სიტყვების ნაკრების გამოყენება", + "Use Library Folder": "ბიბლიოთეკის საქაღალდის გამოყენება", + "Use Screen Profile": "ეკრანის პროფილის გამოყენება", + "Use camera-specific defaults": "კამერისთვის განკუთვნილი ნაგულისხმევი პარამეტრების გამოყენება", + "Use sRGB": "sRGB-ის გამოყენება", + "Use the GPU for rendering": "რენდერირებისთვის GPU-ის გამოყენება", + "User Presets": "მომხმარებლის პრესეტები", + "Username": "მომხმარებლის სახელი", + "Value": "მნიშვნელობა", + "Variance": "დისპერსია", + "Version": "ვერსია", + "Version name": "ვერსიის სახელი", + "Versions": "ვერსიები", + "Versions (Shift+V)": "ვერსიები (Shift+V)", + "Vertical": "ვერტიკალური", + "Vertical inset (% of short edge)": "ვერტიკალური დაშორება (მოკლე გვერდის %)", + "Vertical text": "ვერტიკალური ტექსტი", + "Vibrance": "სიხალასე", + "Video": "ვიდეო", + "Video Duration": "ვიდეოს ხანგრძლივობა", + "Videos": "ვიდეოები", + "View": "ხედი", + "Viewing and sharing": "ნახვა და გაზიარება", + "Vignette": "ვინიეტი", + "Vignetting": "ვინიეტირება", + "Vintage": "ვინტაჟი", + "Virtual Copy": "ვირტუალური ასლი", + "Visible": "ხილული", + "Visualize HDR": "HDR-ის ვიზუალიზაცია", + "Visualize Spots": "ლაქების ვიზუალიზაცია", + "Visualize Spots (A)": "ლაქების ვიზუალიზაცია (A)", + "Visualize range": "დიაპაზონის ვიზუალიზაცია", + "Vivid": "ცოცხალი", + "Vivid Pop": "ცოცხალი და მკვეთრი", + "Wait for the import to finish": "დაელოდეთ იმპორტის დასრულებას", + "Waiting to start…": "დაწყების მოლოდინში…", + "Warm Film": "თბილი ფირი", + "Warm Glow": "თბილი ნათება", + "Warm Print": "თბილი ანაბეჭდი", + "Warm Table": "თბილი მაგიდა", + "Watched folder": "თვალთვალის ქვეშ მყოფი საქაღალდე", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "ავტოიმპორტი თვალთვალის ქვეშ მყოფი საქაღალდიდან; DNG-ად გარდაქმნა; დუბლირება; სტანდარტული / 1:1 / ჭკვიანი წინასწარი ხედების შექმნა.", + "Watermark": "ჭვირნიშანი", + "We": "ოთხ", + "Wednesday": "ოთხშაბათი", + "What's New": "რა არის ახალი", + "When GPU rendering is on but no graphics processor can be found, LightCraft now says so once at start-up instead": "როცა GPU რენდერი ჩართულია, მაგრამ გრაფიკული პროცესორი ვერ მოიძებნა, LightCraft ახლა ამას გაშვებისას ერთხელ გეუბნებათ, ნაცვლად იმისა,", + "When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):": "როცა საქაღალდეში რამდენიმე ტიპის ფაილია, ფოტოების იმპორტის მიმოხილვას თითო ტიპზე გადამრთველი აქვს (`ARW · 120`, `JPG · 120`):", + "When rated or hovered": "შეფასებისას ან კურსორის მიტანისას", + "White Balance": "თეთრის ბალანსი", + "White Balance Selector": "თეთრის ბალანსის ამრჩევი", + "White Balance Selector (W)": "თეთრის ბალანსის ამრჩევი (W)", + "White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the": "თეთრის ბალანსი DNG-ებზე (და ფერის მატრიცის მქონე სხვა RAW-ებზე) ფოტოს ახალი თეთრისთვის ხელახლა ამუშავებს", + "Whites": "თეთრები", + "Width": "სიგანე", + "Width (% of photo)": "სიგანე (ფოტოს %)", + "Width (px)": "სიგანე (პქს)", + "Width × Height": "სიგანე × სიმაღლე", + "Will Export": "ექსპორტირდება", + "Will be replaced": "ჩანაცვლდება", + "Will be restored": "აღდგება", + "Window": "ფანჯარა", + "Winter Blue": "ზამთრის ლურჯი", + "With **HDR output** on, a photo edited in HDR can now be exported as a **16-bit PNG** with the PQ curve in Rec. 2020,": "**HDR გამოტანის** ჩართვისას HDR-ში დამუშავებული ფოტოს ექსპორტი ახლა შესაძლებელია **16-ბიტიან PNG-ად** PQ მრუდით Rec. 2020-ში,", + "With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later,": "GPU-ით სლაიდერის გადათრევის წინასწარი ხედი იმავე კადრში იხატება, რომელშიც სლაიდერი მოძრაობს, და არა ერთი კადრით გვიან,", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "შენახვის გარეშე გასვლა (ხარვეზი #100). ის აღარ იწყებს ჩუმად სადემონსტრაციო სესიას, რომელიც ჩამოყრილ ბიბლიოთეკას ჰგავდა და", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "მუშაობს სტანდარტული (Bayer) სენსორის მქონე კამერების RAW ფაილებზე. სხვა ფაილებს ხმაურის ჩვეულებრივი შემცირება რჩება.", + "XMP Sidecar Preferences": "XMP თანმხლები ფაილების პარამეტრები", + "XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel": "XMP პრესეტები წითელ / მწვანე / ლურჯ მრუდებს მხოლოდ მაშინ იყენებს, როცა მათ მთავარი მრუდიც და სამივე არხის", + "XMP sidecars": "XMP თანმხლები ფაილები", + "Year": "წელი", + "Yellow": "ყვითელი", + "Yellow Hue": "ყვითლის ფერის ტონი", + "Yellow Luminance": "ყვითლის სიკაშკაშე", + "Yellow Saturation": "ყვითლის გაჯერება", + "Yellow label": "ყვითელი იარლიყი", + "Yes": "დიახ", + "Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `\"false\"` now": "დიახ/არა ველები (აქვს ცვლილებები, ჩამოჭრილი, აქვს GPS…) აჩვენებს დიახ / არა-ს, ნაცვლად true / false-ისა. `\"false\"`-ად გაგზავნილი წესი ახლა", + "You can close this: the download continues, and resumes if interrupted.": "შეგიძლიათ დახუროთ: ჩამოტვირთვა გაგრძელდება და შეწყვეტის შემთხვევაში განახლდება.", + "Your Name": "თქვენი სახელი", + "Your library couldn't be opened": "თქვენი ბიბლიოთეკის გახსნა ვერ მოხერხდა", + "Zoom": "მასშტაბი", + "Zoom 100%": "მასშტაბი 100%", + "Zoom In": "გადიდება", + "Zoom Out": "დაპატარავება", + "Zoom to Fit": "ეკრანში ჩატევა", + "Zoom to page level": "გვერდის დონემდე მასშტაბირება", + "Zoomed 64 MP raws stay decoded": "გადიდებული 64 MP RAW ფაილები დეკოდირებული რჩება", + "Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy": "გადიდება, როცა „მანამდე/შემდეგ“ გვერდიგვერდაა, ორიგინალს ერთხელ დეკოდირებს: „მანამდე“ მხარე ადრე საკუთარ ასლს დეკოდირებდა", + "[camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).": "[კამერის წინასწარი ხედის ფერი](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).", + "`*.jpg` case-sensitively, so `.JPG` and `.NEF` files were hidden — issues #342 and #705).": "`*.jpg`-ს რეგისტრის გათვალისწინებით ადარებდა, ამიტომ `.JPG` და `.NEF` ფაილები დამალული იყო — ხარვეზები #342 და #705).", + "`--import ` takes HEIC": "`--import ` HEIC-ს იღებს", + "`--memory` sessions keep their promise to save nothing (issues #164, #169): UI changes made in one no longer": "`--memory` სესიები პირობას ასრულებს, არაფერი შეინახონ (ხარვეზები #164, #169): მათში შეტანილი UI ცვლილებები აღარ", + "`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The": "`.lrcat-data` საქაღალდეებიდან ერთ-ერთის, ან `.lrcat`-ის) მის შიგნით აღარ ქმნის ცარიელ LightCraft-ის ბიბლიოთეკას (ხარვეზი #891).", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) მათ XMP თანმხლებ ფაილებთან ერთად; თითოეული ორიგინალი ბარათს მხოლოდ მაშინ ტოვებს, როცა მისი", + "`Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.": "`Shift+6–9` იარლიყს ანიჭებს და გადადის შემდეგზე. `Shift+P` ფოტოების ბადესა და კვადრატულ ბადეში რჩეულად მონიშნავს და გადადის შემდეგზე; სხვა ხედებში პრესეტებს ხსნის. ავტომატური გადასვლის ჩართვისას Shift მაინც მხოლოდ ერთხელ გადადის.", + "`export.photosImports`. See [Add to Apple Photos](apple-photos.md).": "`export.photosImports`. იხ. [Apple Photos-ში დამატება](apple-photos.md).", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; რეგისტრის მიმართ არამგრძნობიარე ტომებზე რეგისტრის ცვლილება მაინც სრულდება.", + "`lightcraft-cli --library` given a library's own file (`catalog.snap`) opens its folder instead of failing with": "`lightcraft-cli --library`, თუ მას ბიბლიოთეკის საკუთარ ფაილს (`catalog.snap`) მისცემთ, მის საქაღალდეს ხსნის, ნაცვლად შეცდომისა", + "`lightcraft-cli run --import ` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW": "`lightcraft-cli run --import ` (და MCP ხელსაწყოები, რომლებიც საქაღალდეებს იღებს) ახლა HEIC, HEIF და Nikon NRW", + "`lightcraft-cli` logs warnings on stderr too (issue #168); `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level.": "`lightcraft-cli` გაფრთხილებებს stderr-ზეც წერს (ხარვეზი #168); `LIGHTCRAFT_LOG` ან `RUST_LOG` სხვა დონეს ირჩევს.", + "`publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`).": "`publicDomain` (წესები და აგენტები კვლავ იდენტიფიკატორებს იყენებს; `album.ruleFields` ამატებს `choiceLabels`-ს).", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "სავსე დისკი) შექმნილად აღარ ითვლება და ჭკვიანი წინასწარი ხედების შექმნა მას ჩაანაცვლებს.", + "a line, a tab per level, synonyms in braces, keywords left out of export in brackets). Capture One, darktable, Adobe": "თითო სტრიქონზე, თითო ტაბულაცია დონეზე, სინონიმები ფიგურულ ფრჩხილებში, ექსპორტიდან გამორიცხული საკვანძო სიტყვები კვადრატულ ფრჩხილებში). Capture One, darktable, Adobe", + "a literal %": "სიტყვასიტყვითი %", + "a removed photo": "ამოშლილი ფოტო", + "adjust or switch them off, and DNG export keeps them. Lateral chromatic aberration data is not applied: our": "მათ არეგულირებს ან გამორთავს, DNG ექსპორტი კი ინარჩუნებს. ლატერალური ქრომატული აბერაციის მონაცემები არ გამოიყენება: ჩვენი", + "after a rename, an `.XMP` sidecar. Adding photos or a folder, Browse Folder (Local), drag-and-drop, the CLI and the": "გადარქმევის შემდეგ, `.XMP` თანმხლები ფაილი. ფოტოების ან საქაღალდის დამატება, საქაღალდის დათვალიერება (ლოკალური), გადათრევა, CLI და", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "შემდეგ (ხარვეზი #101): ნაწილობრივი ჩანაწერი იკვეთება LightCraft-ის ხელახალ ცდამდე, ასე რომ შემდეგი გაშვება ხელახლა ასრულებს ყოველ", + "again.": "ხელახლა დეკოდირდეს.", + "again. Change the period (or turn it off) in Settings → Performance.": "ხელახლა. პერიოდის შეცვლა (ან გამორთვა) შეგიძლიათ აქ: პარამეტრები → წარმადობა.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "ბიბლიოთეკის ორიგინალებთან შედარება აღარ ათვალიერებს მთელ ბიბლიოთეკას, როცა ამ მისამართზე არაფერია.", + "album that is gone, or an empty group is reported with its rule (\"rule 2.1: no rating 0–5 is 9\")": "გამქრალი ალბომი ან ცარიელი ჯგუფი მოხსენებულია თავისი წესით („წესი 2.1: შეფასება 0–5 არ არის 9“)", + "all": "ყველა", + "alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every": "მარტო ავსებდა, ამიტომ ფოტოს წინასწარი ხედისა და ესკიზის წყაროები, ისევე როგორც მეზობლებისა, RAW-იდან ხელახლა დეკოდირდებოდა", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "უკვე აქვს სხვა სურათების `thumbs/` საქაღალდე, ქეშის შემცირებისას ან გასუფთავებისას მათ აღარ კარგავს.", + "and": "და", + "and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.": "და CR3, ბრტყლად და გაუფერულებულად გახსნის ნაცვლად (ხარვეზი #310). უკვე იმპორტირებული ფოტოები მას ხელახალი რენდერისას იღებს.", + "and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their": "და WB2000. შეკუმშულები (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) კვლავ თავისი", + "and a dark theme card, each previewing its theme (dark: Charcoal, the look so far, or Midnight;": "და ბნელი თემის ბარათების თავზე, თითოეული თავის თემას აჩვენებს (ბნელი: ნახშირი — აქამდელი იერი, ან შუაღამე;", + "and a section with nothing in it says so instead of disappearing.": "ცარიელი სექცია კი ამას ამბობს და არ ქრება.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "და გამოყენებამდე ამოწმებს ფაილს. ნიღბები ინარჩუნებს მონიშნულს, ამიტომ მოდელის გარეშეც რენდერდება და ექსპორტირდება.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "და ყველა იმპორტირებულ ფოტოს ერთ zip-ად, ხოლო ფაილი → ბიბლიოთეკის აღდგენა სარეზერვო ასლიდან… მას აბრუნებს (მიმდინარე ბიბლიოთეკა", + "and exports don't change. Agents use `app.displayProfile`. See [display profiles](display-profiles.md).": "და ექსპორტი არ იცვლება. აგენტები იყენებენ `app.displayProfile`-ს. იხ. [ეკრანის პროფილები](display-profiles.md).", + "and its Cancel / OK buttons were off screen, so it couldn't be confirmed. Now its options scroll and the title and": "და მისი ღილაკები „გაუქმება“ / OK ეკრანს გარეთ იყო, ამიტომ დადასტურება შეუძლებელი იყო. ახლა პარამეტრები გადაიხვევა, სათაური და", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "და ფანჯარას არასოდეს ყინავს. ის არასავალდებულოა და LightCraft-ის ნაწილი არ არის (Meta-ს SAM ლიცენზია, დაახლოებით 3.4 GB):", + "and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere.": "და ჩაშენებული დეფორმაციის / ვინიეტირების კორექციების მქონე სხვა ფაილები), რაც პრესეტის სხვაგან ქცევას ემთხვევა.", + "and press Return; Esc keeps the old value.": "და დააჭირეთ Return-ს; Esc ძველ მნიშვნელობას ტოვებს.", + "and the fit to the photo's own JPEG still come first, so photos that had a fitted look keep it. See": "და ფოტოს საკუთარ JPEG-ზე მორგება კვლავ უპირატესია, ასე რომ მორგებული იერის მქონე ფოტოები მას ინარჩუნებს. იხ.", + "any": "ნებისმიერი", + "app, and with LightCraft fullscreen it was hidden altogether, so Import Photos… looked like a hang until the": "აპის გვერდით აყენებდნენ, სრულეკრანიან LightCraft-თან კი ის საერთოდ დამალული იყო, ასე რომ ფოტოების იმპორტი… გაჭედვას ჰგავდა, სანამ", + "are empty": "ცარიელია", + "are unchanged; photos imported before this change get it when imported again.": "უცვლელია; ამ ცვლილებამდე იმპორტირებული ფოტოები მას ხელახალი იმპორტისას იღებს.", + "aren't empty": "არ არის ცარიელი", + "as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long": "როგორც 1/250), ფაილის გაფართოება, ვიდეოს ხანგრძლივობა, ასლის სახელი, დასტაში, ალტერნატიული ტექსტი, ქალაქი, შტატი / პროვინცია, ქვეყანა, გრძელი", + "as Exposure would; the edges between bright and dark regions stay as sharp as before. This is rendering process 2:": "ისე, როგორც ექსპოზიცია; ნათელ და მუქ არეებს შორის კიდეები ისევე მკვეთრი რჩება. ეს რენდერის მე-2 პროცესია:", + "as `.corrupt-