Merge pull request #363: community PRs, batch 3 (German + Russian UI, Fujifilm compressed RAF, raw fixes)

Integrate community PRs, batch 3: German + Russian UI, Fujifilm compressed RAF, raw fixes (stacked on #350)
This commit is contained in:
BFlat
2026-10-09 02:18:21 +07:00
committed by GitHub
68 changed files with 10980 additions and 370 deletions
Generated
+1
View File
@@ -3029,6 +3029,7 @@ dependencies = [
"proptest",
"rayon",
"serde",
"sha2",
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
+5 -5
View File
@@ -288,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
- **camera colour calibration:** Sony, Nikon, Panasonic and Fujifilm raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **CR3 and compressed Olympus raws:** these open as embedded previews only. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
@@ -302,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
@@ -353,7 +353,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; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) 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
+18 -15
View File
@@ -6,7 +6,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
## Where we stand
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
**switch** from Lightroom. Update it when a gap below closes.*
@@ -23,13 +23,13 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2 and RAF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@@ -37,7 +37,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
@@ -46,9 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
@@ -88,14 +89,14 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
@@ -122,7 +123,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
@@ -171,8 +172,8 @@ private 3:2 & 4:3 examples verified through native rendering & full-resolution 1
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
@@ -183,9 +184,9 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
- **Olympus compressed ORF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW, NEF, RW2 and RAF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
@@ -194,6 +195,8 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
+3 -2
View File
@@ -89,7 +89,7 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -159,7 +159,8 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
let is_raw =
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
+13
View File
@@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) {
std::fs::write(path, png).unwrap();
}
#[test]
fn calibrate_reads_raf_inputs_and_reports_bad_files() {
let input = tmp("invalid.RAF");
std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap();
let out = tmp("raf-profiles");
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
assert!(!result.status.success());
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}");
assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}");
assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
}
fn mean(path: &std::path::Path) -> f64 {
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
let img = d.to_srgb8();
+22 -1
View File
@@ -269,6 +269,16 @@ impl PrefsWriter {
}
}
/// The Windows command that opens `url` in the default browser. `explorer <url>` opened File
/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
/// `?`, `&` and `#` need no escaping.
fn windows_open_url_command(url: &str) -> std::process::Command {
let mut c = std::process::Command::new("rundll32");
c.args(["url.dll,FileProtocolHandler", url]);
c
}
fn services() -> Services {
Services {
pick_folder: Some(Box::new(|| {
@@ -303,7 +313,7 @@ fn services() -> Services {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else if cfg!(target_os = "windows") {
std::process::Command::new("explorer").arg(url).status()
windows_open_url_command(url).status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
@@ -744,6 +754,17 @@ mod tests {
assert!(!b.is_empty());
}
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
#[test]
fn windows_links_open_in_the_browser() {
let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200";
let c = windows_open_url_command(url);
assert_eq!(c.get_program(), "rundll32");
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
}
#[test]
fn a_control_port_that_is_not_a_number_is_ignored() {
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
+1
View File
@@ -12,6 +12,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -2,7 +2,7 @@
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use lightcraft_develop::{DevelopSettings, WbMode};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -336,12 +336,12 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera
/// The current ARW, NEF, RW2 and RAF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@@ -370,7 +370,12 @@ impl Photo {
}
/// Edited by the user: settings differ from what import gave the photo.
pub fn is_edited(&self) -> bool {
*self.develop != self.import_defaults() && !self.develop.is_unedited()
let mut defaults = self.import_defaults();
// As Shot reads the source's current WB; stored numbers can predate a decoder update.
if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot {
defaults.wb = self.develop.wb;
}
*self.develop != defaults && !self.develop.is_unedited()
}
/// Capture time if known, else import time (sort key).
pub fn date(&self) -> &str {
@@ -430,7 +435,7 @@ mod edited_tests {
}
#[test]
fn sony_and_nikon_raws_use_relative_white_balance() {
fn supported_raws_use_relative_white_balance() {
for (name, format, relative) in [
("a.arw", "ARW", true),
("a.nef", "NEF", true),
@@ -438,6 +443,7 @@ mod edited_tests {
("a.rw2", "RW2", true),
("a.rwl", "RWL", true),
("a.raw", "RAW", true),
("a.raf", "rAf", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
@@ -446,6 +452,12 @@ mod edited_tests {
assert_eq!(p.relative_wb(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
let mut custom = (*p.develop).clone();
custom.wb.mode = WbMode::Custom;
p.develop = Arc::new(custom);
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
+85 -12
View File
@@ -1,5 +1,5 @@
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! RW2, Fujifilm RAF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
@@ -31,7 +31,7 @@ pub(crate) const PROFILE_PROXY: usize = 192;
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2)
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
@@ -40,13 +40,13 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
}
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
// A camera profile pooled from many photos knows colours this photo shows too little of;
// only the tone and chroma curves are fitted per photo (DRO and picture styles vary).
// try its colour first and fit tone/chroma per photo (DRO and picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let look = fit_pairs_with(&sensor, &reference, colour)?;
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}",
raw.format,
look.matrix.0,
look.tone,
@@ -101,7 +101,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
let to_camera = transform.matrix.inverse()?;
let (pairs, _) = collect_pairs(&sensor, &reference, None)?;
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
}
@@ -173,7 +173,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
/// colours (see [`EDGE_CONTRAST`]).
type Pairs = Vec<([f64; 3], [f64; 3])>;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
}
@@ -197,7 +197,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
let max = output.iter().copied().fold(0.0, f32::max);
colour += usize::from(max - min > 0.05);
colour += usize::from(max - min > min_chroma);
pairs.push((input.map(f64::from), output.map(f64::from)));
}
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
@@ -252,11 +252,22 @@ const EDGE_CONTRAST: f32 = 3.0;
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
/// valid when the camera JPEG's geometry differs slightly from the raw's.
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
let has_profile = colour.is_some();
// A different picture style can make a camera profile fail the gates. Preserve the
// photo's own colour fit before resorting to the neutral fallback.
fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
}
/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
/// the pixels away from edges (issue #232).
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
}
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
let (pairs, bright) = collect_pairs(sensor, reference, edge_limit)?;
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
let candidates = match colour {
Some(given) => vec![given],
None => {
@@ -778,7 +789,7 @@ mod tests {
let mut pool = Vec::new();
for seed in 0..4 {
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
pool.extend(collect_pairs(&sensor, &reference, None).unwrap().0);
pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
}
let (matrix, _) = fit_profile(&pool).unwrap();
// the camera's colours (luminance-normalised: the tone curve sets brightness)
@@ -805,6 +816,48 @@ mod tests {
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
}
#[test]
fn profile_fits_tone_without_learning_colour_from_a_dull_scene() {
let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]);
let mut sensor = Rgb32f::new(64, 48);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
*src = [0.02 + (i % 997) as f32 * 0.00045; 3];
let p = matrix.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
}
assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix");
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
assert_eq!(look.matrix, matrix);
reference.map_in_place(|p| [luminance_2020(p); 3]);
assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected");
}
#[test]
fn rejected_profile_preserves_a_usable_per_photo_fit() {
let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
let mut seed = 0x2545_f491_u32;
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
*src = std::array::from_fn(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
0.05 + (seed % 1000) as f32 * 0.0003
});
let p = matrix.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999));
}
let per_photo = fit_pairs(&sensor, &reference).unwrap();
let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap();
assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit");
assert_eq!(look.tone, per_photo.tone);
}
#[test]
fn hue_sat_passes_black_and_non_finite_pixels_through() {
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
@@ -816,9 +869,9 @@ mod tests {
}
#[test]
fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
fn supported_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look));
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
@@ -918,6 +971,26 @@ mod tests {
assert!(fit_pairs(&sensor, &reference).is_none());
}
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes;
/// a poor reference keeps the fallback. Skips cleanly without these CC0 corpus files.
#[test]
fn corpus_raf_colour_and_white_balance() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", false)] {
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let header = crate::files::probe_bytes(name, &bytes).unwrap();
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
}
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
+8 -4
View File
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -24,7 +24,11 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))];
pub const BUNDLED: &[(&str, &str)] = &[
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CameraProfile {
@@ -196,8 +200,8 @@ pub struct Pool {
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
/// NEF without colour matrices, no usable camera JPEG, too little colour).
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW,
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// colour only: geometric lens corrections would stop the sensor proxy from binning
+21 -18
View File
@@ -14,24 +14,27 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, WbMode};
let mut s = demo();
let id = PhotoId(100);
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
// A catalog made by the old generic-matrix Kelvin inference.
p.as_shot_wb = Some((6829.0, -127.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
let d = active_dev(&s);
assert_eq!(d.wb.mode, WbMode::Custom);
assert_eq!(d.wb.temp, 8000.0);
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
assert_eq!(active_dev(&s).wb.temp, 6500.0);
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
for format in ["ARW", "NEF", "NRW", "RAF"] {
let mut s = demo();
let id = PhotoId(100);
let name = format!("synthetic.{format}");
let mut p = Photo::new(id, Source::File { path: name.clone() }, &name, format, 16, 16, "");
// A catalog made by the old generic-matrix Kelvin inference.
p.as_shot_wb = Some((6829.0, -127.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
let d = active_dev(&s);
assert_eq!(d.wb.mode, WbMode::Custom);
assert_eq!(d.wb.temp, 8000.0);
assert_eq!(d.wb.tint, 0.0, "{format}: untouched tint comes from the current as-shot reference");
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
assert_eq!(active_dev(&s).wb.temp, 6500.0, "{format}");
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0), "{format}");
}
}
#[test]
+2
View File
@@ -24,3 +24,5 @@ zune-core = { workspace = true }
[dev-dependencies]
jpeg-encoder = { workspace = true }
proptest = { workspace = true }
# Pinned CC0 corpus identities; not a product dependency.
sha2 = { version = "0.10", default-features = false, features = ["std"] }
+2 -2
View File
@@ -13,10 +13,10 @@
//!
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! and X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon "lossy after split" NEF,
//! compressed ORF/RAF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources
//! compressed ORF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources
//! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no
//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input.
#![forbid(unsafe_code)]
+81 -3
View File
@@ -15,14 +15,15 @@
//! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image
//! area; the masked columns left of it give the black level.
//! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known
//! offsets and accept the first plausible quadruple.
//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED
//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first.
//! - sRAW / mRAW (YCbCr, subsampled) are not supported yet.
use super::{black_from_columns, white_from_data};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::chunk_bytes;
use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t};
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t};
const CR2_SLICE: u16 = 0xc640;
const SRAW_TYPE: u16 = 0xc6c5;
@@ -46,6 +47,17 @@ fn cfa_from_tag(v: Option<u64>) -> Option<Cfa> {
Some(Cfa::bayer_static(name))
}
/// The 16-bit words of a maker-note array. Canon writes `ColorData` as SHORT in most models, but as UNDEFINED
/// bytes (count = byte length, e.g. 5120) in the PowerShot / EOS M models with ColorData versions -3 and -4;
/// those bytes are the same 16-bit words in the maker note's byte order, so they are paired up, not read one by
/// one. Other types are returned value by value.
fn words(v: &Value, order: ByteOrder) -> Vec<u64> {
match v {
Value::Byte(b) | Value::Undefined(b) => b.as_chunks::<2>().0.iter().map(|c| u64::from(order.u16(*c))).collect(),
_ => v.to_u64_vec(),
}
}
/// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array.
fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> {
for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] {
@@ -134,7 +146,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
}
}
let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v));
let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v));
let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB"));
let black = if active.x >= 8 {
black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active)
@@ -175,6 +187,11 @@ pub(crate) mod tests {
/// Build a synthetic CR2: IFD0..IFD2 placeholders + IFD3 holding a sliced 2-component LJ92 frame.
pub(crate) fn synthetic_cr2(w: usize, h: usize, slices: Option<[u16; 3]>) -> (Vec<u8>, Vec<u16>) {
synthetic_cr2_note(w, h, slices, None)
}
/// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored).
fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option<Vec<u8>>) -> (Vec<u8>, Vec<u16>) {
let img: Vec<u16> = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect();
let widths: Vec<usize> = match slices {
Some([n, sw, last]) => {
@@ -200,6 +217,9 @@ pub(crate) mod tests {
// maker note: SensorInfo + ColorBalance, plain IFD with offsets relative to the TIFF header
let mut exif = IfdBuilder::new();
exif.set(t::ISO_SPEED, Value::Short(vec![400]));
if let Some(n) = note {
exif.set(t::MAKER_NOTE, Value::Undefined(n));
}
ifd0.set_child(t::EXIF_IFD, exif);
let mut ifd3 = IfdBuilder::new();
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
@@ -251,4 +271,62 @@ pub(crate) mod tests {
assert!((wb[0] - 2000.0 / 1025.0).abs() < 1e-4 && (wb[2] - 1500.0 / 1025.0).abs() < 1e-4);
assert!(wb_from_color_balance(&[0; 10]).is_none());
}
#[test]
fn wb_from_undefined_bytes() {
// hand-coded: ColorData of an S110-like file, UNDEFINED bytes, little-endian words; word 71.. = 1988 745 745 1641
let mut b = vec![0u8; 2 * 120];
b[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3
b[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]);
let le = words(&Value::Undefined(b.clone()), ByteOrder::Little);
assert_eq!(&le[71..75], &[1988, 745, 745, 1641]);
let wb = wb_from_color_balance(&le).unwrap();
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5);
// read as single bytes (the old behaviour) nothing is plausible
assert!(wb_from_color_balance(&b.iter().map(|&x| u64::from(x)).collect::<Vec<_>>()).is_none());
// big-endian note: same words, bytes swapped
let mut be = b.clone();
be.as_chunks_mut::<2>().0.iter_mut().for_each(|c| c.swap(0, 1));
assert_eq!(words(&Value::Undefined(be), ByteOrder::Big), le);
// SHORT values pass through, an odd trailing byte is ignored
assert_eq!(words(&Value::Short(vec![1, 2, 3]), ByteOrder::Little), vec![1, 2, 3]);
assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]);
}
/// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain
/// IFD whose value offset is relative to the TIFF header (patched once the note's position is known).
fn note_with_color_data(ty: u16, data: &[u8]) -> Vec<u8> {
let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32;
let mut note = Vec::new();
note.extend_from_slice(&1u16.to_le_bytes());
note.extend_from_slice(&COLOR_BALANCE.to_le_bytes());
note.extend_from_slice(&ty.to_le_bytes());
note.extend_from_slice(&count.to_le_bytes());
note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below
note.extend_from_slice(&0u32.to_le_bytes()); // next IFD
note.extend_from_slice(data);
note
}
fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage {
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data)));
let tiff = Tiff::parse(&bytes).unwrap();
let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap();
let off = e.offset as usize;
bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes());
crate::decode(&bytes).unwrap()
}
/// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71,
/// exactly as the same words stored as SHORT do.
#[test]
fn color_data_stored_as_bytes() {
let mut bytes = vec![0u8; 2 * 120];
bytes[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3
bytes[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); // 1988 745 745 1641
for ty in [7, 3] {
let wb = decode_with_color_data(ty, &bytes).wb_multipliers.unwrap_or_else(|| panic!("type {ty}: no WB"));
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}");
}
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod nefc;
pub mod orf;
pub mod pef;
pub mod raf;
mod rafc;
pub mod rw2;
use crate::{BlackLevel, Rect};
+65 -3
View File
@@ -30,7 +30,13 @@
//! first two pixels of the previous row of the same parity, starting at the seeds of maker note `0x0096`.
//! - Lossless (`0x0096` version `0x46`): the decoded values are the samples. Lossy (version `0x44`): they index a
//! curve. Type 1 (`0x44 0x10`) stores the full curve; type 2 (`0x44 0x20`, `0x44 0x40`) stores 257 points,
//! one every `2^bits / 256` codes, linearly interpolated.
//! one every `2^bits / 256` codes (`0x20`) or `2^bits / 1024` codes (`0x40`), linearly interpolated. The span
//! was established from the files: in every `0x20` file the first interval of the 257 points rises by
//! `2^bits / 256` (16 at 12 bit, 64 at 14 bit) and in every `0x40` file by `2^bits / 1024` (4 and 16), i.e.
//! the curve is the identity in the dark part, one code per sample. Read with a point every `2^bits / 256`
//! codes instead, the 12 `0x40` files that are not split decoded to values that never exceeded 255 (12 bit) or
//! 1023 (14 bit) and whose darkest 0.01 % sat at a quarter of the black level of maker note `0x003d` (63 against
//! 252 in 12-bit units); with the first-interval step they sit at that black level.
//! - Values in the maker note are in the maker note's byte order (newer bodies write little-endian notes).
//!
//! Not supported (returned as [`RawError::Unsupported`], the embedded preview is used instead):
@@ -127,8 +133,10 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result<Decod
"Nikon \"lossy after split\" compressed NEF (split at row {split}) is not decoded yet"
)));
}
// type 2: `n` points, one every 2^bits / (n - 1) codes
let range = 1usize << bits;
// type 2: `n` points covering `range` codes, one every `range / (n - 1)`; the curve's first interval
// rises by exactly that step (identity in the dark part) and the table version sets the span:
// `0x20` covers all 2^bits codes, `0x40` the first quarter (see the module docs)
let range = if v1 == 0x40 { 1usize << (bits - 2) } else { 1usize << bits };
if n - 1 > range || !range.is_multiple_of(n - 1) {
return Err(RawError::Unsupported(format!("NEF: {n}-point lossy curve for {bits}-bit data")));
}
@@ -355,6 +363,60 @@ mod tests {
}
}
/// Maker note `0x0096` as bytes, written out by hand: version `0x44 <v1>`, four seeds, 257 points (little-endian
/// as newer bodies write it) of a curve whose first 256 points rise by `step` and whose last is the white
/// level, and a zero split value at offset 562.
fn hand_table(v1: u8, seed: u16, step: u16, white: u16) -> Vec<u8> {
let mut t = vec![0x44, v1];
for _ in 0..4 {
t.extend_from_slice(&seed.to_le_bytes());
}
t.extend_from_slice(&257u16.to_le_bytes());
for k in 0..256u16 {
t.extend_from_slice(&(k * step).to_le_bytes());
}
t.extend_from_slice(&white.to_le_bytes());
t.resize(624, 0);
t
}
/// Known answers for the `0x44 0x40` curve, from bytes and bit strings written by hand (nothing here uses the
/// decoder's span rule or the test encoder): the first interval of the points rises by `2^bits / 1024`, so
/// the curve is the identity for one code per sample and spans `2^bits / 4` codes. The `0x20` curve of the
/// same sizes keeps its span of all `2^bits` codes.
#[test]
fn lossy_type2_version_0x40_spans_a_quarter_of_the_range() {
// 12 bit: points 0, 4, 8 … 1020, 4095
let t = parse_table(&hand_table(0x40, 252, 4, 4095), ByteOrder::Little, 12).unwrap();
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
assert_eq!(curve.len(), 1025);
assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]);
assert_eq!((curve[252], curve[255], curve[1020]), (252, 255, 1020));
assert_eq!((curve[1021], curve[1022], curve[1023], curve[1024]), (1788, 2557, 3326, 4095));
// the pixels p0 = 252 + 4, p1 = 252, p2 = p0 - 1, p3 = p1, coded as 011 100 | 11110 | 1110 0 | 11110
// (LOSSY_12: category 3 = 011, 0 = 11110, 1 = 1110; extra bits 100 = +4, 0 = -1)
let out = decode(&[0x73, 0xdc, 0xf0], 4, 1, 12, &t).unwrap();
assert_eq!(out, [256, 252, 255, 252]);
// 14 bit: points 0, 16, 32 … 4080, 16383
let t = parse_table(&hand_table(0x40, 1008, 16, 16383), ByteOrder::Little, 14).unwrap();
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
assert_eq!(curve.len(), 4097);
assert_eq!(curve[0..6], [0, 1, 2, 3, 4, 5]);
assert_eq!((curve[1008], curve[4080], curve[4096]), (1008, 4080, 16383));
// the same pixels coded as 1100 100 | 111110 | 11110 0 | 111110
// (LOSSY_14: category 3 = 1100, 0 = 111110, 1 = 11110)
let out = decode(&[0xc9, 0xf7, 0x9f, 0x00], 4, 1, 14, &t).unwrap();
assert_eq!(out, [1012, 1008, 1011, 1008]);
// version 0x20 with the same points is a curve over all 2^bits codes: the points are 16 codes apart
let t = parse_table(&hand_table(0x20, 252, 16, 4095), ByteOrder::Little, 12).unwrap();
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
assert_eq!((curve.len(), curve[16], curve[17], curve[4095]), (4097, 16, 17, 4094));
// 200 points can't be spaced evenly over the 1024 codes of a 12-bit 0x40 table
let mut odd = hand_table(0x40, 252, 4, 4095);
odd[10..12].copy_from_slice(&200u16.to_le_bytes());
assert!(parse_table(&odd, ByteOrder::Little, 12).is_err());
}
#[test]
fn lossy_type1_full_curve() {
let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5];
+65 -24
View File
@@ -1,4 +1,4 @@
//! Fujifilm RAF — uncompressed Bayer and X-Trans.
//! Fujifilm RAF — uncompressed, lossless compressed and lossy compressed Bayer and X-Trans.
//!
//! Sources: the ExifTool FujiFilm tag-name documentation (RAF header record tags `0x0100` RawImageFullSize,
//! `0x0110` RawImageCropTopLeft, `0x0111` RawImageCroppedSize, `0x0131` XTransLayout; raw-IFD tags `0xf001`
@@ -17,7 +17,7 @@
//! is anchored at raw pixel (0, 0) — verified on X-Trans I and III samples by minimising the difference
//! between horizontally / vertically adjacent "green" samples over all 72 orientations/phases, and by colour
//! renders. Bayer bodies (X-A series) use RGGB at (0, 0), verified by colour renders.
//! - Fujifilm's compressed RAF variants are reported as unsupported (their embedded preview still works).
//! - Striped predictive compression is decoded by `rafc`; see `docs/raf-compression.md`.
use super::white_from_data;
use crate::unpack::{unpack_lsb, unpack_msb};
@@ -118,35 +118,56 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let h = header(bytes)?;
let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?;
let ifd = raw_ifd(raw)?;
let get = |t: u16| ifd.u64(t).map(|v| v as usize);
let (w, hgt) = (get(0xf001).unwrap_or(0), get(0xf002).unwrap_or(0));
let bits = get(0xf003).unwrap_or(0) as u32;
let (off, len) = (get(0xf007).unwrap_or(0), get(0xf008).unwrap_or(0));
let get = |t: u16| ifd.u64(t).map(|v| usize::try_from(v).map_err(|_| RawError::Limit("RAF IFD value too large"))).transpose();
let (w, hgt) = (get(0xf001)?.unwrap_or(0), get(0xf002)?.unwrap_or(0));
let bits = ifd.u64(0xf003).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let (off, len) = (get(0xf007)?.unwrap_or(0), get(0xf008)?.unwrap_or(0));
if w == 0 || hgt == 0 || !(8..=16).contains(&bits) {
return Err(RawError::Corrupt(format!("RAF raw IFD: {w}x{hgt}, {bits} bits")));
}
let n = w.checked_mul(hgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
// samples of any depth may be stored in 16-bit words (e.g. 14-bit X-T20); otherwise packed at `bits`
let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits };
let packed_row = if store == 16 { w * 2 } else { (w * store as usize).div_ceil(8) };
let stride = match get(0xf005).unwrap_or(0) {
0 => packed_row,
words => (words * 4).max(packed_row),
let src = raw
.get(off..off.checked_add(len).ok_or_else(|| RawError::Corrupt("RAF strip size overflow".into()))?)
.ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?;
let cfa = cfa(&h);
let compression = be32(bytes, 108).unwrap_or(0);
let compressed = match compression {
0 => false,
2 | 3 => true,
_ => return Err(RawError::Unsupported(format!("Fujifilm RAF compression mode {compression}"))),
};
if (len as u64) < (stride as u64) * (hgt as u64 - 1) + packed_row as u64 {
return Err(RawError::Unsupported(format!("Fujifilm compressed RAF ({len} bytes for {w}x{hgt} {bits}-bit)")));
}
let src = raw.get(off..off.saturating_add(len).min(raw.len())).ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?;
let mut data = Vec::new();
if mode == Mode::Full {
data = vec![0u16; n];
data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]);
unpack_row(s, store, row);
});
if compressed {
if cfa.width == 6 && cfa != Cfa::xtrans() {
return Err(RawError::Unsupported("Fujifilm compressed RAF with an unknown X-Trans layout".into()));
}
let flag = if compression == 2 { 1 } else { 0 };
if src.get(2) != Some(&flag) {
return Err(RawError::Corrupt("RAF container and stream compression modes disagree".into()));
}
data = super::rafc::decode(src, w, hgt, bits, cfa.width == 6, mode)?;
} else {
// Samples may use 16-bit words (e.g. 14-bit X-T20); otherwise they are packed at `bits`.
let store = if len as u64 >= (n as u64) * 2 { 16 } else { bits };
let packed_row = w.checked_mul(store as usize).map(|n| n.div_ceil(8)).ok_or(RawError::Limit("RAF row too large"))?;
let stride = match get(0xf005)?.unwrap_or(0) {
0 => packed_row,
words => words.checked_mul(4).ok_or(RawError::Limit("RAF stride too large"))?.max(packed_row),
};
let required = stride.checked_mul(hgt - 1).and_then(|n| n.checked_add(packed_row)).ok_or(RawError::Limit("RAF strip too large"))?;
if len < required {
return Err(RawError::Unsupported(format!("Fujifilm RAF packing ({len} bytes for {w}x{hgt} {bits}-bit)")));
}
if mode == Mode::Full {
data = vec![0u16; n];
data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
// `required <= src.len()` proves these row offsets fit, including the final row.
let s = src.get(y * stride..y * stride + packed_row).unwrap_or(&[]);
unpack_row(s, store, row);
});
}
}
let cfa = cfa(&h);
let active = match (pair(&h, CROP_TOP_LEFT), pair(&h, CROPPED_SIZE)) {
(Some((top, left)), Some((ch, cw))) if ch > 0 && cw > 0 => Rect::new(left, top, cw, ch).clipped(w, hgt),
_ => Rect::new(0, 0, w, hgt),
@@ -194,7 +215,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
}
#[cfg(test)]
mod tests {
pub(super) mod tests {
use super::*;
/// Build a minimal RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD).
@@ -247,6 +268,10 @@ mod tests {
dir.extend_from_slice(&recs);
let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec();
out.resize(108, 0);
if strip.starts_with(b"IS") {
let compression = if strip.get(2) == Some(&0) { 3u32 } else { 2u32 };
out.extend_from_slice(&compression.to_be_bytes());
}
let jpeg_off = 160u32;
let dir_off = jpeg_off + jpeg.len() as u32;
let raw_off = dir_off + dir.len() as u32;
@@ -310,8 +335,24 @@ mod tests {
}
let bytes = raf(w, h, 16, pack(&[0; 96], 16), None, b"");
assert_eq!(crate::decode(&bytes).unwrap().cfa.unwrap().name(), "RGGB");
let mut px = vec![0; 96];
px[0] = 0x5349;
let mut bytes = raf(w, h, 16, pack(&px, 16), None, b"");
bytes[108..112].copy_from_slice(&0u32.to_be_bytes());
assert_eq!(crate::decode(&bytes).unwrap().data, RawData::U16(px));
// too little data for the size: compressed
let bytes = raf(w, h, 14, vec![0; 20], None, b"");
assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))));
}
#[test]
fn overflowing_row_stride_is_an_error() {
let mut bytes = raf(16, 6, 16, vec![0; 192], None, &[]);
let off = be32(&bytes, 100).unwrap();
// Replace WB with a LONG8 stride, pointing to the existing out-of-line WB storage.
bytes[off + 100..off + 104].copy_from_slice(&[0x05, 0xf0, 16, 0]);
bytes[off + 104..off + 108].copy_from_slice(&1u32.to_le_bytes());
bytes[off + 116..off + 124].copy_from_slice(&u64::MAX.to_le_bytes());
assert!(matches!(crate::decode(&bytes), Err(RawError::Limit(_))));
}
}
+505
View File
@@ -0,0 +1,505 @@
//! Fujifilm's striped predictive RAW compression.
//!
//! Clean-room implementation: Fabian's prose description of lossless RAF compression
//! (https://capnfabs.net/posts/fuji-raf-compression-algorithm/) supplied the broad structure;
//! headers, colour-vector placement, predictors, contexts and adaptation were recovered
//! from sample bytes and black-box comparisons of decoded sensor arrays. No other RAW
//! decoder's source was consulted. See docs/raf-compression.md for verification details.
use crate::{Mode, RawError, Result};
use rayon::prelude::*;
const BLOCK: usize = 768;
const XTRANS: [[usize; 6]; 6] =
[[1, 1, 0, 1, 1, 2], [1, 1, 2, 1, 1, 0], [2, 0, 1, 0, 2, 1], [1, 1, 2, 1, 1, 0], [1, 1, 0, 1, 1, 2], [0, 2, 1, 2, 0, 1]];
const MASKS: [[u8; 2]; 6] = [[10, 15], [15, 10], [14, 10], [15, 11], [11, 15], [10, 14]];
fn corrupt(why: &str) -> RawError {
RawError::Corrupt(format!("Fujifilm compressed RAF: {why}"))
}
fn be16(src: &[u8], at: usize) -> Result<usize> {
let s = src.get(at..at.saturating_add(2)).ok_or_else(|| corrupt("truncated header"))?;
let [a, b] = s else { return Err(corrupt("truncated header")) };
Ok(u16::from_be_bytes([*a, *b]) as usize)
}
struct Stripe<'a> {
bytes: &'a [u8],
quantizers: Option<&'a [u8]>,
width: usize,
}
pub(super) fn decode(src: &[u8], width: usize, height: usize, bits: u32, xtrans: bool, mode: Mode) -> Result<Vec<u16>> {
let h = src.get(..16).ok_or_else(|| corrupt("truncated header"))?;
if !h.starts_with(b"IS") {
return Err(corrupt("missing compression signature"));
}
let flag = h.get(2).copied().ok_or_else(|| corrupt("truncated header"))?;
if flag > 1 {
return Err(corrupt("invalid compression flag"));
}
let sensor = h.get(3).copied().ok_or_else(|| corrupt("truncated header"))?;
if sensor != if xtrans { 16 } else { 0 } {
return Err(corrupt("compression sensor type disagrees with CFA"));
}
if !matches!(bits, 12 | 14 | 16) {
return Err(RawError::Unsupported(format!("Fujifilm compressed RAF at {bits} bits")));
}
let count = h.get(13).copied().ok_or_else(|| corrupt("truncated header"))? as usize;
if h.get(4).copied() != Some(bits as u8)
|| be16(h, 5)? != height
|| be16(h, 9)? != width
|| count == 0
|| count != width.div_ceil(BLOCK)
|| be16(h, 7)? != count * BLOCK
|| be16(h, 11)? != BLOCK
|| height == 0
|| !height.is_multiple_of(6)
|| be16(h, 14)? != height / 6
{
return Err(corrupt("inconsistent compression dimensions"));
}
let n = width.checked_mul(height).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let table_end = 16 + count * 4;
let mut offset = table_end.div_ceil(16) * 16;
let quantizer_start = offset;
let quantizer_stride = (height / 6).div_ceil(16) * 16;
if flag == 0 {
offset += quantizer_stride * count;
if src.get(quantizer_start..offset).is_none() {
return Err(corrupt("truncated quantizer tables"));
}
}
let mut stripes = Vec::with_capacity(count);
for i in 0..count {
let entry = src.get(16 + i * 4..20 + i * 4).ok_or_else(|| corrupt("truncated stripe size table"))?;
let [a, b, c, d] = entry else { return Err(corrupt("truncated stripe size table")) };
let size = u32::from_be_bytes([*a, *b, *c, *d]) as usize;
let end = offset.checked_add(size).ok_or_else(|| corrupt("stripe size overflow"))?;
let bytes = src.get(offset..end).filter(|s| !s.is_empty()).ok_or_else(|| corrupt("stripe outside file"))?;
let quantizers = if flag == 0 {
Some(
src.get(quantizer_start + i * quantizer_stride..quantizer_start + i * quantizer_stride + height / 6)
.ok_or_else(|| corrupt("truncated quantizer table"))?,
)
} else {
None
};
stripes.push(Stripe { bytes, quantizers, width: (width - i * BLOCK).min(BLOCK) });
offset = end;
}
if mode == Mode::Header {
return Ok(Vec::new());
}
let decoded: Vec<Vec<u16>> = stripes.par_iter().map(|s| decode_stripe(s, height, bits, xtrans)).collect::<Result<_>>()?;
let mut output = vec![0; n];
for (i, (stripe, data)) in stripes.iter().zip(&decoded).enumerate() {
for (row, samples) in output.chunks_exact_mut(width).zip(data.chunks_exact(stripe.width)) {
row.get_mut(i * BLOCK..i * BLOCK + stripe.width).ok_or_else(|| corrupt("invalid stripe placement"))?.copy_from_slice(samples);
}
}
Ok(output)
}
struct Bits<'a> {
src: &'a [u8],
pos: usize,
acc: u64,
available: u32,
}
impl<'a> Bits<'a> {
fn new(src: &'a [u8]) -> Self {
Self { src, pos: 0, acc: 0, available: 0 }
}
#[inline]
fn get(&mut self, n: u32) -> Result<u32> {
while self.available < n {
let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?;
self.pos += 1;
self.acc = (self.acc << 8) | u64::from(b);
self.available += 8;
}
self.available -= n;
Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32)
}
fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result<i32> {
let mut zeros = 0;
while self.get(1)? == 0 {
zeros += 1;
if zeros > limit {
return Err(corrupt("invalid residual escape"));
}
}
let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? };
if code > range {
return Err(corrupt(&format!(
"residual {code} outside sample range {range} (k={k}, prefix={zeros}, bits={bits}, position={})",
self.pos * 8 - self.available as usize
)));
}
Ok(if code & 1 == 0 { (code / 2) as i32 } else { -((code / 2) as i32) - 1 })
}
}
#[derive(Clone, Copy)]
struct State {
sum: u32,
count: u32,
}
impl State {
fn k(self) -> u32 {
let mut k = 0;
while self.count << k < self.sum {
k += 1;
}
k
}
fn update(&mut self, delta: i32) {
self.sum += delta.unsigned_abs();
if self.count == 64 {
self.sum >>= 1;
self.count >>= 1;
}
self.count += 1;
}
}
#[inline]
fn quant(v: i32, near: u32) -> i32 {
let t = [18 + 3 * near, 67 + 5 * near, 276 + 7 * near];
let a = v.unsigned_abs();
let bucket = if a <= near {
0
} else if a < t[0] {
1
} else if a < t[1] {
2
} else if a < t[2] {
3
} else {
4
};
if v < 0 { -bucket } else { bucket }
}
#[inline]
fn even_prediction(nw: i32, n: i32, ne: i32, nn: i32) -> i32 {
let (a, b, c) = ((nw - n).abs(), (ne - n).abs(), (nn - n).abs());
let pair = if a > b && a > c {
ne + nn
} else if b > a && b > c {
nw + nn
} else {
nw + ne
};
(pair + 2 * n) >> 2
}
#[inline]
fn sample(rows: &[Vec<i32>], y: usize, x: usize) -> Result<i32> {
rows.get(y).and_then(|r| r.get(x)).copied().ok_or_else(|| corrupt("invalid predictor position"))
}
fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> Result<Vec<u16>> {
let size = if xtrans { BLOCK * 2 / 3 } else { BLOCK / 2 };
let mut previous = vec![vec![vec![0; size + 2]; 2]; 3];
let max = (1u32 << bits) - 1;
let range = |near: u32| (max + 2 * near) / (2 * near + 1) + 1;
let seed = |near: u32| State { sum: ((range(near) + 32) / 64).max(2), count: 1 };
let mut states = [[[seed(0); 41]; 2]; 3];
// Smooth regions retain finer precision and independent state across block quantizer changes.
let mut fine = [[[[seed(0); 5]; 2]; 3], [[[seed(1); 5]; 2]; 3], [[[seed(2); 5]; 2]; 3]];
let mut last_near = None;
let mut reader = Bits::new(stripe.bytes);
let n = height.checked_mul(stripe.width).filter(|&n| n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let mut output = Vec::with_capacity(n);
for group in 0..height / 6 {
let near = match stripe.quantizers {
Some(q) => u32::from(*q.get(group).ok_or_else(|| corrupt("truncated quantizer table"))?),
None => 0,
};
if last_near != Some(near) {
states = [[[seed(near); 41]; 2]; 3];
last_near = Some(near);
}
let mut rows = previous;
for pair in 0..6 {
let colours = if pair % 2 == 0 { [0, 1] } else { [1, 2] };
for &colour in &colours {
let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?;
let mut row = vec![0; size + 2];
let prev = lines.len() - 1;
let left = sample(lines, prev, 1)?;
let right = sample(lines, prev, size)?;
if let Some(v) = row.first_mut() {
*v = left;
}
if let Some(v) = row.last_mut() {
*v = right;
}
lines.push(row);
}
for even in (0..size + 8).step_by(2) {
for (odd, position) in [(0, Some(even)), (1, even.checked_sub(7))] {
let Some(pos) = position.filter(|&x| x < size) else { continue };
let x = pos + 1;
for (which, &colour) in colours.iter().enumerate() {
let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?;
let y = lines.len() - 1;
let (nw, north, ne, nn) =
(sample(lines, y - 1, x - 1)?, sample(lines, y - 1, x)?, sample(lines, y - 1, x + 1)?, sample(lines, y - 2, x)?);
let west = sample(lines, y, x - 1)?;
let east = sample(lines, y, x + 1)?;
let (predict, d1, d2) = if odd == 0 {
(even_prediction(nw, north, ne, nn), north - nn, nw - north)
} else {
let p = if (nw.min(ne)..=nw.max(ne)).contains(&north) { (west + east) >> 1 } else { (west + east + 2 * north) >> 2 };
(p, north - nw, nw - west)
};
let real = !xtrans || MASKS.get(pair).and_then(|m| m.get(which)).is_some_and(|m| (m >> (pos % 4)) & 1 != 0);
let value = if real {
let contrast = d1.unsigned_abs() + d2.unsigned_abs();
let precision = match contrast {
0..=5 => 0,
6 => near.min(1),
7 => near.min(2),
_ => near,
};
let (context, state) = if precision < near {
let sign = |v: i32| if v.unsigned_abs() <= precision { 0 } else { v.signum() };
let context = 3 * sign(d1) + sign(d2);
let state = fine
.get_mut(precision as usize)
.and_then(|f| f.get_mut(pair % 3))
.and_then(|s| s.get_mut(odd))
.and_then(|s| s.get_mut(context.unsigned_abs() as usize))
.ok_or_else(|| corrupt("invalid fine coding context"))?;
(context, state)
} else {
let context = 9 * quant(d1, near) + quant(d2, near);
let state = states
.get_mut(pair % 3)
.and_then(|s| s.get_mut(odd))
.and_then(|s| s.get_mut(context.unsigned_abs() as usize))
.ok_or_else(|| corrupt("invalid coding context"))?;
(context, state)
};
let coded_range = range(precision);
let coded_bits = 32 - (coded_range - 1).leading_zeros();
let mut delta = reader.residual(state.k(), coded_bits, coded_range, 4 * bits - coded_bits - 1)?;
state.update(delta);
if context < 0 {
delta = -delta;
}
let step = (2 * precision + 1) as i32;
let mut value = predict + delta * step;
if value < -(precision as i32) {
value += coded_range as i32 * step;
} else if value > (max + precision) as i32 {
value -= coded_range as i32 * step;
}
value.clamp(0, max as i32)
} else {
predict
};
*lines.get_mut(y).and_then(|r| r.get_mut(x)).ok_or_else(|| corrupt("invalid predictor position"))? = value;
}
}
}
}
for y in 0..6 {
for x in 0..stripe.width {
let colour = if xtrans {
XTRANS.get(y).and_then(|r| r.get(x % 6)).copied().ok_or_else(|| corrupt("invalid CFA position"))?
} else {
match (y % 2, x % 2) {
(0, 0) => 0,
(1, 1) => 2,
_ => 1,
}
};
let row = 2 + if colour == 1 { y } else { y / 2 };
let col = 1 + if xtrans { 2 * (x / 3) + (x % 3).min(1) } else { x / 2 };
output.push(sample(rows.get(colour).ok_or_else(|| corrupt("invalid colour vector"))?, row, col)? as u16);
}
}
previous = rows.into_iter().map(|mut r| r.split_off(r.len() - 2)).collect();
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Cfa;
use proptest::prelude::*;
/// Procedural zero residuals: no camera media or encoder dependency.
fn zero_stream(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec<u8> {
let groups = quantizers.map_or(3, <[u8]>::len);
let mut main = [[(1u32 << (bits - 6), 1u32); 2]; 3];
let mut fine = main;
let mut previous = None;
let mut stream = Vec::new();
for group in 0..groups {
let q = quantizers.map_or(0, |q| q[group]);
if previous != Some(q) {
main = [[(1u32 << (bits - 6), 1u32); 2]; 3];
previous = Some(q);
}
for pair in 0..6 {
let width = if xtrans { 512 } else { 384 };
for even in (0usize..width + 8).step_by(2) {
for (parity, column) in [(0, Some(even)), (1, even.checked_sub(7))] {
let Some(column) = column.filter(|&x| x < width) else { continue };
for colour in 0..2 {
if xtrans && (MASKS[pair][colour] >> (column % 4)) & 1 == 0 {
continue;
}
let (sum, count) = &mut if q == 0 { &mut main } else { &mut fine }[pair % 3][parity];
let k = 32 - (sum.saturating_sub(1) / *count).leading_zeros();
stream.push(true);
stream.extend(std::iter::repeat_n(false, k as usize));
if *count == 64 {
*sum /= 2;
*count /= 2;
}
*count += 1;
}
}
}
}
}
stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect()
}
fn compressed(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec<u8> {
let groups = quantizers.map_or(3, <[u8]>::len);
let stream = zero_stream(bits, xtrans, quantizers);
// Two stripes; the second is mostly padding, which must never enter the output.
let mut out = b"IS".to_vec();
out.extend_from_slice(&[u8::from(quantizers.is_none()), if xtrans { 16 } else { 0 }, bits as u8]);
for v in [groups as u16 * 6, 1536, 780, 768] {
out.extend_from_slice(&v.to_be_bytes());
}
out.push(2);
out.extend_from_slice(&(groups as u16).to_be_bytes());
for _ in 0..2 {
out.extend_from_slice(&(stream.len() as u32).to_be_bytes());
}
out.resize(32, 0);
if let Some(q) = quantizers {
for _ in 0..2 {
out.extend_from_slice(q);
out.resize(out.len() + q.len().div_ceil(16) * 16 - q.len(), 0);
}
}
out.extend_from_slice(&stream);
out.extend_from_slice(&stream);
out
}
#[test]
fn lossless_and_lossy_bayer_and_xtrans_stripes() {
for bits in [12, 14, 16] {
for xtrans in [false, true] {
for q in [None, Some([2, 0, 5].as_slice())] {
let src = compressed(bits, xtrans, q);
let out = decode(&src, 780, 18, bits, xtrans, Mode::Full).unwrap();
assert_eq!(out, vec![0; 780 * 18]);
assert!(decode(&src, 780, 18, bits, xtrans, Mode::Header).unwrap().is_empty());
let layout = xtrans.then(|| {
let mut l = [0; 36];
for (to, from) in l.iter_mut().zip(XTRANS.iter().flatten().rev()) {
*to = *from as u8;
}
l
});
let raf = super::super::raf::tests::raf(780, 18, bits, src, layout, &[]);
let image = crate::decode(&raf).unwrap();
assert_eq!(crate::probe_info(&raf).unwrap(), image.info());
assert_eq!(image.data, crate::RawData::U16(out));
}
}
}
}
#[test]
fn malformed_headers_and_truncated_entropy_are_errors() {
let valid = compressed(14, true, Some(&[1, 2, 0]));
for len in (0..96).chain([valid.len() / 2, valid.len() - 1]) {
assert!(decode(&valid[..len], 780, 18, 14, true, Mode::Full).is_err(), "length {len}");
}
for at in [2, 3, 4, 5, 7, 9, 11, 13, 14, 16] {
let mut src = valid.clone();
src[at] ^= 0xff;
assert!(decode(&src, 780, 18, 14, true, Mode::Full).is_err(), "header byte {at}");
}
assert!(decode(&valid, usize::MAX, usize::MAX, 14, true, Mode::Full).is_err());
let mut reader = Bits::new(&[0; 64]);
assert!(reader.residual(0, 14, 16384, 41).is_err());
}
#[test]
fn unsupported_container_modes_and_cfa_are_not_silently_decoded() {
let layout = std::array::from_fn(|i| Cfa::xtrans().pattern[35 - i]);
let src = compressed(14, true, None);
let raf = super::super::raf::tests::raf(780, 18, 14, src, Some(layout), &[]);
let mut mismatched = raf.clone();
mismatched[108..112].copy_from_slice(&3u32.to_be_bytes());
assert!(crate::decode(&mismatched).is_err(), "declared lossy mode disagrees with lossless stream");
let bad_layout = layout.map(|c| {
if c == 0 {
2
} else if c == 2 {
0
} else {
c
}
});
let raf = super::super::raf::tests::raf(780, 18, 14, compressed(14, true, None), Some(bad_layout), &[]);
assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "compression uses a fixed X-Trans arrangement");
let mut raf = super::super::raf::tests::raf(780, 18, 16, vec![0; 780 * 18 * 2], None, &[]);
for mode in [1u32, 4, u32::MAX] {
raf[108..112].copy_from_slice(&mode.to_be_bytes());
assert!(matches!(crate::decode(&raf), Err(RawError::Unsupported(_))), "unknown mode {mode}");
assert!(matches!(crate::probe_info(&raf), Err(RawError::Unsupported(_))), "header-only mode {mode}");
}
}
#[test]
fn residual_sign_wrap_and_escape() {
// Ordinary codes 0, 1, 2, 3 with k=2; then the escape for -8192.
let mut stream = vec![true, false, false, true, false, true, true, true, false, true, true, true];
stream.extend(std::iter::repeat_n(false, 41));
stream.push(true);
let escaped = 16382u32;
stream.extend((0..14).rev().map(|i| (escaped >> i) & 1 != 0));
let bytes: Vec<u8> = stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect();
let mut reader = Bits::new(&bytes);
for delta in [0, -1, 1, -2, -8192] {
assert_eq!(reader.residual(2, 14, 16384, 41).unwrap(), delta);
}
assert_eq!(even_prediction(1112, 858, 1104, 1152), 983);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn malformed_streams_never_panic(data in prop::collection::vec(any::<u8>(), 0..2048), quantizer in any::<u8>(), bits in prop::sample::select(vec![12,14,16]), xtrans in any::<bool>()) {
let mut src = compressed(bits, xtrans, Some(&[quantizer]));
let start = 64;
src.truncate(start);
for i in [16, 20] { src[i..i+4].copy_from_slice(&(data.len() as u32).to_be_bytes()); }
src.extend_from_slice(&data);
src.extend_from_slice(&data);
let _ = decode(&src, 780, 6, bits, xtrans, Mode::Full);
}
}
}
+79 -4
View File
@@ -15,10 +15,9 @@ fn corpus_root() -> PathBuf {
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
const KNOWN_UNSUPPORTED: &[&str] = &[
"cr3-", // CR3 / CRX (M11.1)
"raf-fuji-xt20-compressed", // Fujifilm compressed RAF
"orf-olympus-em", // Olympus compressed ORF
"sraw", // Canon sRAW / mRAW
"cr3-", // CR3 / CRX (M11.1)
"orf-olympus-em", // Olympus compressed ORF
"sraw", // Canon sRAW / mRAW
];
#[test]
@@ -166,6 +165,34 @@ fn corpus_cr2_cfa_patterns() {
eprintln!("CR2 CFA layouts checked on {seen} files");
}
/// The as-shot white balance of Canon CR2s whose ColorData is stored as SHORT words stays readable (the PowerShot /
/// EOS M files that store it as UNDEFINED bytes are covered by the unit test in `vendor/cr2.rs`; none is in this corpus).
/// Daylight-ish CR2s have R/G above 1 and B/G between 1 and 2.5 (observed 1.4-2.7 on these nine bodies).
#[test]
fn corpus_cr2_as_shot_white_balance() {
let dir = corpus_root().join("raw");
let mut seen = 0;
for name in [
"cr2-canon-40d.cr2",
"cr2-canon-550d.cr2",
"cr2-canon-5d2.cr2",
"cr2-canon-5d3.cr2",
"cr2-canon-5dsr.cr2",
"cr2-canon-6d.cr2",
"cr2-canon-7d.cr2",
"cr2-canon-80d.cr2",
] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB"));
assert!(wb[1] == 1.0 && wb[0] > 1.0 && wb[2] > 0.5 && wb[0] < 4.0 && wb[2] < 4.0, "{name}: WB {wb:?}");
seen += 1;
}
eprintln!("CR2 as-shot WB checked on {seen} files");
}
/// raw.pixls.us has the same D5100 scene as 14-bit lossless compressed and uncompressed NEF: the Huffman decode must
/// match the uncompressed image (up to the small differences between two exposures).
#[test]
@@ -456,3 +483,51 @@ fn corpus_orf_cfa_patterns() {
}
eprintln!("ORF CFA layouts checked on {seen} files");
}
/// Reference sensor sums and position-weighted sums from black-box decoding, independent
/// of this implementation. These cover complete images, padding, stripe boundaries and
/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`.
#[test]
fn corpus_fujifilm_compressed_samples() {
use sha2::{Digest, Sha256};
let checksums = include_str!("../../../docs/raf-corpus.sha256");
let cases: &[(&str, usize, usize, u64, u64)] = &[
("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080),
("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551),
("raf-fuji-gfx100rf-8091.raf", 11808, 8754, 509005112862, 6771145666297463071),
("raf-fuji-gfx100s-4495.raf", 11808, 8754, 941987648006, 4811558780180901389),
("raf-fuji-gfx100s-4503.raf", 11808, 8754, 939580201129, 4821879272388485929),
("raf-fuji-gfx50s-1435.raf", 9216, 6210, 79545293042, 2565169091480702981),
("raf-fuji-xe5-8509.raf", 7872, 5196, 76435253154, 1299804440011842144),
("raf-fuji-xh2-6001.raf", 7872, 5196, 83480264024, 1799924693528487099),
("raf-fuji-xh2-6002.raf", 7872, 5196, 72476569659, 1547275125734054244),
("raf-fuji-xm5-7748.raf", 6336, 4182, 93243605328, 986712869515471030),
("raf-fuji-xt2-865.raf", 6048, 4038, 44212684244, 541983062567959797),
("raf-fuji-xt20-compressed.raf", 6048, 4038, 72690241711, 882852288583469317),
("raf-fuji-xt4-3914.raf", 6384, 4182, 45943146703, 599112804573868025),
("raf-fuji-xt4-3918.raf", 6384, 4182, 46401490361, 605690093061448074),
("raf-fuji-xt5-6122.raf", 7872, 5196, 109559716516, 1653376659872066392),
("raf-fuji-xt5-6123.raf", 7872, 5196, 109489917369, 1673725237502600190),
("raf-fuji-xt50-7807.raf", 7872, 5196, 76453904143, 1262190863834170778),
];
let dir = corpus_root().join("raw");
let mut seen = 0;
for &(name, width, height, sum, weighted) in cases {
let path = format!("corpus/raw/{name}");
let checksum = checksums.lines().filter_map(|s| s.split_once(" ")).find(|(_, p)| *p == path).unwrap().0;
assert_eq!(checksum.len(), 64, "{name}: missing published SHA-256");
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let actual_sha: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(actual_sha, checksum, "{name}: corpus file differs from its pinned identity");
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((img.width, img.height), (width, height), "{name}");
let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") };
let actual = data
.iter()
.enumerate()
.fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v)))));
assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference");
seen += 1;
}
eprintln!("verified {seen} Fujifilm compressed sensor arrays");
}
+180
View File
@@ -0,0 +1,180 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} mit mehreren ähnlichen Dateien: „Fehlende Datei suchen“ verwenden)",
" ({} in Recently Deleted)": " ({} in „Zuletzt gelöscht“)",
" · {:.0}% clipped": " · {:.0}% beschnitten",
" · {failed} couldn't be rendered": " · nicht gerenderte Fotos: {failed}",
" · {skipped} skipped (file exists)": " · {skipped} übersprungen (Datei vorhanden)",
" · {} duplicate{} skipped": " · {} übersprungen (Duplikate){:.0}",
" · {} failed: {}": " · {} fehlgeschlagen: {}",
" · {} moved": " · {} verschoben",
" · {} not readable": " · {} nicht lesbar",
" · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}",
" · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt",
" — not carried over: {}{}": " — nicht übernommen: {}{}",
"+{} more": "+{} weitere",
", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}",
"; {kept} already had a location": "; bereits verortet: {kept}",
"; {outside} outside its time range": "; {outside} außerhalb des Zeitbereichs",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} Commits, +{} / −{} Zeilen (Δ {}), +{} / −{} Binärdateien, {} – {}",
"Add {local_n} to My Photos": "{local_n} zu „Meine Fotos“ hinzufügen",
"Add {} Photo{}": "{} Foto{} hinzufügen",
"Add {} Photo{}, restore {}": "{} Foto{} hinzufügen, {} wiederherstellen",
"Add “{}” to Favorites": "„{}“ zu Favoriten hinzufügen",
"Added {n} photo{} to My Photos": "{n} Foto{} zu „Meine Fotos“ hinzugefügt",
"Added {n} photo{} to “{}”": "{n} Foto{0} zu „{1}“ hinzugefügt",
"Added {what} to {name}": "{what} zu {name} hinzugefügt",
"Adding photos… {} of {}": "Fotos werden hinzugefügt… {} von {}",
"All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren",
"Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt",
"Auto ({})": "Automatisch ({})",
"Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt",
"Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos",
"Automatic ({} MB)": "Automatisch ({} MB)",
"Build 1:1 Previews ({scope})": "1:1-Vorschauen erstellen ({scope})",
"Build Smart Previews ({scope})": "Smart-Vorschauen erstellen ({scope})",
"Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})",
"Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…",
"Click {}:1": "Klick {}:1",
"Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}",
"Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen",
"Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}",
"Decoder: {reason}": "Decoder: {reason}",
"Delete \"{label}\"": "„{label}“ löschen",
"Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)",
"Delete {n} Photos": "{n} Fotos löschen",
"Delete “{current}”": "„{current}“ löschen",
"Discard Smart Previews ({scope})": "Smart-Vorschauen verwerfen ({scope})",
"Done ({what}): {done} photo{plural}": "Fertig ({what}): {done} Foto{plural}",
"Duplicate \"{label}\"": "„{label}“ duplizieren",
"Example: {example}": "Beispiel: {example}",
"Exported {n} photo{}": "{n} Foto{} exportiert",
"Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert",
"Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}",
"Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}",
"Exporting {} of {total}": "{} von {total} wird exportiert",
"Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}",
"Focus {:.0}": "Schärfe {:.0}",
"Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}",
"Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}",
"Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt",
"Import failed: {e}": "Import fehlgeschlagen: {e}",
"Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}",
"Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Importierte Vorgaben: {n}{0:.0}, nicht lesbare Dateien: {f}{1:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importierte Profile: {profiles}{:.0} (Profilbrowser → jeweilige Gruppen)",
"Imported {} photo{}": "{} Foto{} importiert",
"Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)",
"Label Set: {name}": "Farbmarkierungssatz: {name}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.",
"Matches: {}": "Treffer: {}",
"Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?",
"Named ({named_n})": "Benannt ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet",
"Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt",
"Preset: {name}": "Vorgabe: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})",
"Preview only — {}": "Nur Vorschau — {}",
"Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig",
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Softproof-Kopie ({})",
"Proof Preview · {}": "Softproof-Vorschau · {}",
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
"Quick Develop: {name}": "Schnellentwicklung: {name}",
"Rated {}": "Bewertet mit {}",
"Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}",
"Reading photos… {} of {}": "Fotos werden gelesen… {} von {}",
"Redo {}": "Wiederholen: {}",
"Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?",
"Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen",
"Removed {what} from {name}": "{what} aus {name} entfernt",
"Rename {n} Photos…": "{n} Fotos umbenennen…",
"Rename “{name}”": "„{name}“ umbenennen",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Benennt „{from}“ bei {n} Foto{} um (auch untergeordnete Stichwörter). Verwende | für Ebenen, z. B. Reise|Italien. Bei vorhandenem Namen werden beide zusammengeführt.",
"Rendering on the CPU: {why}": "Rendering auf der CPU: {why}",
"Replace {} with:": "{} ersetzen durch:",
"Reset {name}": "{name} zurücksetzen",
"Reset {} Channel": "Kanal {} zurücksetzen",
"Restore {} Photo{}": "{} Foto{} wiederherstellen",
"Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).",
"Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}",
"Showing {n} of {}": "{n} von {} werden angezeigt",
"Smart album: {}": "Smart-Album: {}",
"Tagged {n} photo{} from the tracklog": "{n} Foto{} anhand des GPS-Tracks verortet",
"The SAM 3 download failed: {e}": "SAM-3-Download fehlgeschlagen: {e}",
"Undo {}": "Rückgängig: {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)",
"Updated “{name}”": "„{name}“ aktualisiert",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.",
"Version: {}": "Version: {}",
"Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}",
"Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…",
"none (CPU rendering): {why}": "keine (CPU-Rendering): {why}",
"the {n} selected photos": "die {n} ausgewählten Fotos",
"the {} photos in view": "die {} Fotos in der Ansicht",
"these {count} photos": "diese {count} Fotos",
"{count} smart preview{} · {:.1} MB": "Smart-Vorschauen: {count}{0:.0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} Ordner (und ihre Unterordner)",
"{label} Grading": "Farbtonung: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} für jedes ausgewählte Foto",
"{label} {index} of {n}": "{label} {index} von {n}",
"{label}, {n} photos": "{label}, Fotos: {n}",
"{matching} of {t} photos": "Fotos: {matching} von {t}",
"{matching} photos": "Fotos: {matching}",
"{mode} spot {} of {}": "{mode}: Stelle {} von {}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}/{year}",
"{msg} ({failed} failed)": "{msg} ({failed} fehlgeschlagen)",
"{name} Label": "Farbmarkierung {name}",
"{name} Off": "{name} aus",
"{name} On": "{name} ein",
"{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt",
"{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut",
"{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt",
"{n} photos": "Fotos: {n}",
"{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)",
"{n} photo{}": "{n} Foto{}",
"{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{pid} (missing)": "{pid} (fehlt)",
"{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb}: {n} Foto{}",
"{weekday}, {day}": "{weekday}, {day}.",
"{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}",
"{what} merge added": "{what}-Zusammenfügung hinzugefügt",
"{what} merge failed: {e}": "{what}-Zusammenfügung fehlgeschlagen: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "{{date:…}}-Formatangaben: {}",
"{} and higher": "{} und höher",
"{} contributors · {} commits": "{} Mitwirkende · {} Commits",
"{} failed": "{} fehlgeschlagen",
"{} files": "{} Dateien",
"{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}",
"{} item{}": "Elemente: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.",
"{} photos": "Fotos: {}",
"{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{} Foto{} nicht lesbar",
"{} sample{}": "Farbproben: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",
"{} — show their photos": "{} — Fotos dieser Person anzeigen",
"· {dups} already in the library": "· {dups} bereits in der Bibliothek",
"· {n} photo{}": "· {n} Foto{}",
"… and {more} more": "… und {more} weitere",
"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 kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.",
"Filtered: {label}": "Gefiltert: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht ▸ Filter löschen zeigt alle Fotos",
"Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft",
"{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)",
", {} star{}": ", Sterne: {}{:.0}",
", {} label": ", Markierung: {}"
}
File diff suppressed because it is too large Load Diff
+55 -1
View File
@@ -122,5 +122,59 @@
"{weekday}, {day}": "{day}日({weekday})",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
"{year}": "{year}年",
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.0}"
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(うち{unsure}枚は似たファイルが複数あります。「場所を指定」を使ってください)",
" · {:.0}% clipped": "・{:.0}%が白とび/黒つぶれ",
" · {failed} couldn't be rendered": "・{failed}枚は描画できませんでした",
" · {} original{} left at the source": "・元のファイル{}枚を読み込み元に残しました{:.0}",
" — not carried over: {}{}": " — 引き継がなかった設定:{}{}",
"+{} more": "ほか{}件",
"; {kept} already had a location": "。{kept}枚はすでに位置情報あり",
"; {outside} outside its time range": "。{outside}枚は記録時間の範囲外",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:PR {}件、コミット {}件、+{} / −{} 行(Δ {})、バイナリアセット +{} / −{}、{} – {}",
"Added {what} to {name}": "{what}を{name}に追加しました",
"Auto Import: added {} photo{}": "自動読み込み:{}枚の写真を追加しました{:.0}",
"Building previews for {} photos…": "{}枚のプレビューを作成中…",
"Couldn't open the editor: {e}": "エディターを開けませんでした:{e}",
"Creates {} stacks from {} of {scope}": "{scope}の{1}枚から{0}個のスタックを作成します",
"Done ({what}): {done} photo{plural}": "完了({what}):{done}枚{plural:.0}",
"Focus {:.0}": "ピント {:.0}",
"Folder “{}” (and its subfolders)": "フォルダ「{}」(サブフォルダを含む)",
"Found {n} missing photo{}; {left} still missing{unsure}": "見つからない写真を{n}枚見つけました{:.0}。{left}枚はまだ見つかりません{unsure}",
"Import failed: {e}": "読み込みに失敗しました:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles}個のプロファイルを読み込みました{:.0}(プロファイルブラウザー ▸ 各グループ)",
"Insert at the cursor (also written {})": "カーソル位置に挿入({}とも表記可)",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。フォント:{}(OFL)。アイコン:オリジナル",
"Painting “{k}”: click photos to add or remove it · Esc stops": "「{k}」を適用中:写真をクリックして付け外し・Escで終了",
"Previews ready for {done} photo{plural}": "{done}枚のプレビューが完成しました{plural:.0}",
"Removed {what} from {name}": "{what}を{name}から除去しました",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "{n}枚の写真の「{from}」の名前を変更します{:.0}(下位のキーワードも含む)。階層は | で区切ります(例:旅行|イタリア)。既存の名前にすると2つを統合します",
"Replace {} with:": "{}を次に置換:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "{scope}のピントと露出を評価し、数秒以内に撮影した似た写真(連写)を検出します",
"Tagged {n} photo{} from the tracklog": "GPS軌跡から{n}枚の写真に位置情報を付けました{:.0}",
"The SAM 3 download failed: {e}": "SAM 3のダウンロードに失敗しました:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "不明なタグ{s:.0} {} は入力したまま残ります{:.0}(「タグ」を参照)",
"Virtual copy “{name}” of {of}": "{of}の仮想コピー「{name}」",
"Working on {what} for {} photos…": "{}枚の写真の{what}を処理中…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "{name}を編集用に開きました。元の写真とスタックにしてあります",
"{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} — LightCraft will retry": "{n}件の変更{:.0}はメモリに保存しましたが、ディスクには書き込めていません:{e} — LightCraftが再試行します",
"{pid} (missing)": "{pid}(見つかりません)",
"{{date:…}} directives: {}": "{{date:…}} の指定子:{}",
"{} contributors · {} commits": "コントリビューター {}人・コミット {}件",
"{} failed": "{}に失敗しました",
"{} item{}": "{}項目{:.0}",
"{} photos · exposures {} EV": "{}枚・露出 {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{}枚・{}・{:.0}° × {:.0}°",
"{} sample{}": "サンプル{}個{:.0}",
"{} sec": "{}秒",
"{} — show their photos": "{} — この人物の写真を表示",
"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プレビューを編集しています。このプレビューには、カメラのピクチャースタイル(モノクロなど)とホワイトバランスがすでに反映されています",
"Filtered: {label}": "フィルター適用中:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "似た写真{n}枚{:.0}・すべて表示するには「表示 ▸ フィルターを解除」",
"Moved; {} photo(s) relinked": "移動しました。{}枚の写真の参照先を更新しました",
"{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)",
", {} star{}": "、星{}つ{:.0}",
", {} label": "、{}ラベル"
}
+538 -1
View File
@@ -1,13 +1,33 @@
{
" (library default)": " (ライブラリの既定)",
" (inverted)": " (反転)",
" · best of its burst": " · 連写内のベスト",
" · in a burst": " · 連写の一部",
"% of all commits": "全コミットに占める%",
"+ Group": "+ グループ",
"+ Rule": "+ 条件",
", picked": "、採用",
", rejected": "、不採用",
"0: nothing is rejected.": "0:不採用にしません",
"1 day": "1日",
"2026-04 or 2026-04-12": "2026-04 または 2026-04-12",
"= exactly": "= と等しい",
"A blank template keeps the original names.": "テンプレートが空の場合は元の名前のままです",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "フィルターで写真が非表示になっています。条件を変更するか、「表示 → フィルターを解除」を選んでください",
"A one-time download of about": "1回限りのダウンロード:約",
"AI Mask Model Status": "AIマスクモデルの状態",
"About": "LightCraftについて",
"About LightCraft": "LightCraftについて",
"Active": "アクティブ",
"Activity": "処理状況",
"Add": "追加",
"Add Catchlight": "キャッチライトを追加",
"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 rule (⌥-click: a group)": "条件を追加(⌥クリックでグループ)",
"Add in place": "現在の場所から追加",
@@ -17,8 +37,11 @@
"Add to": "追加先",
"Add to Album": "アルバムに追加",
"Add to Favorites": "お気に入りに追加",
"Add to Local": "ローカルに追加",
"Add to My Photos": "マイフォトに追加",
"Add to Selected Photos": "選択した写真に追加",
"Add to Target Album": "追加先アルバムに追加",
"Add to this mask": "このマスクに追加",
"Add/Subtract/Intersect Mask": "マスクを追加/減算/交差",
"Added to photos you import that don't already have it.": "読み込む写真に、この情報がない場合だけ追加します",
"Adding": "追加中",
@@ -38,15 +61,27 @@
"Alt Text": "代替テキスト",
"Always": "常に",
"Amount": "適用量",
"Analyzing the photo for AI masks…": "AIマスク用に写真を解析中…",
"Any Searchable Text": "検索可能なすべてのテキスト",
"Any camera": "すべてのカメラ",
"Any keyword": "すべてのキーワード",
"Any lens": "すべてのレンズ",
"Aperture": "絞り",
"Application": "アプリケーション",
"Apply": "適用",
"Apply Color Label Set": "カラーラベルセットを適用",
"Apply Filter Preset": "フィルタープリセットを適用",
"Apply Metadata Preset": "メタデータプリセットを適用",
"Apply Point Curve Preset": "ポイントカーブのプリセットを適用",
"Apply Preset": "プリセットを適用",
"Apply Settings JSON": "設定JSONを適用",
"Approved": "承認済み",
"Aqua": "アクア",
"Aqua Hue": "アクアの色相",
"Aqua Luminance": "アクアの輝度",
"Aqua Saturation": "アクアの彩度",
"ArtCraft Website": "ArtCraftのウェブサイト",
"ArtCraft — more creative apps": "ArtCraft — その他のクリエイティブアプリ",
"As Shot": "撮影時の設定",
"As shot": "撮影時の設定",
"Ascending": "昇順",
@@ -61,18 +96,26 @@
"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 settings applied": "自動設定を適用しました",
"Auto-Stack by Capture Time": "撮影日時で自動スタック",
"Auto-Stack by Capture Time…": "撮影日時で自動スタック…",
"Auto-Tag Photos from Tracklog": "GPS軌跡から写真に位置情報を自動で付ける",
"Auto-Tag from Tracklog": "GPS軌跡から位置情報を付ける",
"Auto-Tag from Tracklog…": "GPS軌跡から位置情報を付ける…",
"Automatic": "自動",
"Automatically Write Changes into XMP": "変更をXMPに自動保存",
"Automatically write changes into XMP sidecars": "変更をXMPサイドカーファイルに自動保存",
"Autumn Gold": "オータムゴールド",
"B&W": "白黒",
"B&W Mix": "白黒ミックス",
"B&W Mixer": "白黒ミキサー",
@@ -82,17 +125,25 @@
"Back to Grid": "グリッドに戻る",
"Background": "背景",
"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": "ブレンド",
"Blue": "ブルー",
"Blue Hour Boost": "ブルーアワー強調",
"Blue Hue": "ブルーの色相",
"Blue Luminance": "ブルーの輝度",
"Blue Primary": "ブルー原色",
@@ -101,7 +152,9 @@
"Blue/Yellow Fringe": "青/黄のフリンジ",
"Blurry photos below the score are flagged as rejects.": "基準値未満のぼけた写真に不採用フラグを付けます",
"Boundary Warp": "境界の変形",
"Bright & Airy": "明るく軽やか",
"Bring All to Front": "すべてを手前に移動",
"Browse Folder": "フォルダを参照",
"Browse Folder…": "フォルダを参照…",
"Browse Profiles": "プロファイルを参照",
"Browse…": "参照…",
@@ -112,42 +165,70 @@
"By Date": "日付別",
"By day (YYYY/YYYY-MM-DD)": "日別(YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "月別(YYYY/YYYY-MM)",
"CPU threads": "CPUスレッド数",
"Calibration": "キャリブレーション",
"Camera": "カメラ",
"Camera make and model": "カメラのメーカーと機種",
"Cameras and Cards": "カメラとメモリーカード",
"Cancel": "キャンセル",
"Cancel AI Mask Model Download": "AIマスクモデルのダウンロードをキャンセル",
"Cancel Download": "ダウンロードをキャンセル",
"Cancel Interaction": "操作をキャンセル",
"Cancel Preview Build": "プレビュー作成をキャンセル",
"Candidate": "候補",
"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… を付けます",
"Check All": "すべて選択",
"Check Output Path": "書き出し先のパスを確認",
"Checking names…": "名前を確認中…",
"Choose Edit Settings to Copy": "コピーする編集設定を選択",
"Choose Edit Settings to Copy…": "コピーする編集設定を選択…",
"Choose Folder…": "フォルダを選択…",
"Choose a photo in the grid or filmstrip": "グリッドまたはフィルムストリップから写真を選択してください",
"Choose…": "選択…",
"Cinematic": "シネマティック",
"City": "市区町村",
"Clarity": "明瞭度",
"Clean Studio": "クリーンスタジオ",
"Clear": "消去",
"Clear Cache": "キャッシュを消去",
"Clear Filters": "フィルターを解除",
"Clear Guides": "ガイドを消去",
"Clear History": "履歴を消去",
"Clear Preview Cache": "プレビューキャッシュを消去",
"Clear Quick Collection": "クイックコレクションを空にする",
"Clear Reference": "参照写真を解除",
"Clear all": "すべて解除",
"Cleared History": "履歴を消去しました",
"Click Zoom Ratio": "クリック時の拡大率",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "タグをクリックすると挿入できます。例は2026年1月14日05:58:48撮影のIMG_0042.CR3です",
"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": "クリックで復元・ダブルクリックで名前を変更",
"Clone": "コピースタンプ",
"Close": "閉じる",
"Close Panel": "パネルを閉じる",
"Close Window": "ウィンドウを閉じる",
"Cloudy": "曇り",
"Collapse All Stacks": "すべてのスタックを閉じる",
"Collapse Stack": "スタックを折りたたむ",
"Color": "カラー",
"Color Label": "カラーラベル",
"Color Label Names": "カラーラベル名",
"Color Label Sets": "カラーラベルセット",
"Color label cleared": "カラーラベルを解除しました",
"Color Grading": "カラーグレーディング",
"Color Mixer": "カラーミキサー",
@@ -156,18 +237,29 @@
"Color Range": "色範囲",
"Color space": "色空間",
"Color space: sRGB (AVIF)": "色空間:sRGB(AVIF)",
"Colors": "カラー",
"Command Journal": "コマンド記録",
"Commits": "コミット",
"Company": "企業",
"Compare": "比較",
"Compare (Shift+C)": "比較(⇧C)",
"Compare Before and After": "編集前と編集後を比較",
"Compare needs at least two photos": "比較には2枚以上の写真が必要です",
"Component options": "構成要素の設定",
"Compression": "圧縮",
"Concrete Cool": "クールコンクリート",
"Confirm before deleting": "削除前に確認",
"Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認",
"Constrain Crop": "切り抜きを制限",
"Continue": "続行",
"Contrast": "コントラスト",
"Contributors": "コントリビューター",
"Convert to 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": "履歴の設定を編集前にコピー",
@@ -198,18 +290,36 @@
"Create Version from Step": "この履歴からバージョンを作成",
"Create Virtual Copy": "仮想コピーを作成",
"Creator": "作成者",
"Creator metadata": "メタデータの作成者",
"Crisp Landscape": "くっきり風景",
"Crisp Vista": "クリアな眺望",
"Crop": "切り抜き",
"Crop & Rotate": "切り抜きと回転",
"Crop & Rotate (C)": "切り抜きと回転(C)",
"Crop Aspect": "切り抜きの縦横比",
"Crop, Rotate, Geometry": "切り抜き・回転・ジオメトリ",
"Cross Process": "クロスプロセス",
"Culling": "写真の選別",
"Curve": "カーブ",
"Custom": "カスタム",
"Custom template…": "カスタムテンプレート…",
"Custom…": "カスタム…",
"Cycle Crop Overlay": "切り抜きの重ね表示を切り替え",
"Cycle Crop Overlay Orientation": "切り抜きの重ね表示の向きを切り替え",
"Cycle Info Overlay": "情報オーバーレイを切り替え",
"Cycle Mask Overlay Color": "マスクの重ね表示の色を切り替え",
"Cycle Mask Overlay Mode": "マスクの重ね表示モードを切り替え",
"Cylindrical": "円筒",
"Dark & Moody": "ダーク&ムーディー",
"Darken": "暗くする",
"Darks": "暗部",
"Date": "日付",
"Date Groups": "日付グループ",
"Day": "日",
"Daylight": "昼光",
"Decrease Brush Feather": "ブラシのぼかしを小さく",
"Decrease Brush Size": "ブラシのサイズを小さく",
"Deep Sky": "ディープスカイ",
"Defringe": "フリンジ軽減",
"Deghost Amount": "ゴースト除去量",
"Dehaze": "かすみの除去",
@@ -217,12 +327,25 @@
"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 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": "すべてのスポットを削除",
@@ -231,50 +354,85 @@
"Density": "密度",
"Depth Range": "深度範囲",
"Descending": "降順",
"Describe": "説明で選択",
"Describe what to keep": "残すものを説明",
"Describe what to leave out": "除外するものを説明",
"Describe what to select": "選択するものを説明",
"Describe…": "説明で選択…",
"Deselect All": "選択を解除",
"Desert Heat": "デザートヒート",
"Desert Warmth": "デザートウォーム",
"Desktop": "デスクトップ",
"Detail": "詳細",
"Detail (D)": "詳細(D)",
"Details": "詳細",
"Diagonal": "対角線",
"Discard Preview Cache": "プレビューキャッシュを破棄",
"Display P3": "Display P3",
"Display name": "表示名",
"Distortion": "ゆがみ",
"Don't enlarge": "拡大しない",
"Done": "完了",
"Download": "ダウンロード",
"Download AI Mask Model": "AIマスクモデルをダウンロード",
"Download the SAM 3 Model?": "SAM 3モデルをダウンロードしますか?",
"Downloads": "ダウンロード",
"Drag along the horizon; double-click for Auto": "水平線に沿ってドラッグ(ダブルクリックで自動補正)",
"Drag over an eye on the photo; the pupil inside is found automatically.": "写真の目をドラッグすると、瞳孔を自動検出します",
"Draw Guides": "ガイドを描く",
"Duplicate": "複製",
"Duplicate Component": "構成要素を複製",
"Duplicate Mask": "マスクを複製",
"Duplicate and Invert Mask": "マスクを複製して反転",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "「/」でフォルダ階層を区切ります。タグは写真ごとに展開し、情報がない階層は「unknown」になります",
"Edge": "境界",
"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 Info": "情報を編集",
"Edit Label Names": "ラベル名を編集",
"Edit Label Names…": "ラベル名を編集…",
"Edit Mask": "マスクを編集",
"Edit Mask Component": "マスクの構成要素を編集",
"Edit Sections": "編集項目",
"Edit Smart Album": "スマートアルバムを編集",
"Edit Smart Album…": "スマートアルバムを編集…",
"Edit Spot": "スポットを編集",
"Edit in External Editor": "外部エディターで編集",
"Edit settings copied": "編集設定をコピーしました",
"Edited": "編集済み",
"Edited or not": "編集の有無を問わない",
"Editing": "編集",
"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": "消去",
"Every EXIF, GPS and XMP field in the file": "ファイル内のすべてのEXIF・GPS・XMP情報",
"Existing previews": "既存のプレビュー",
"Expand All Stacks": "すべてのスタックを展開",
"Expand Stack": "スタックを展開",
"Expand/Collapse Stack": "スタックを展開/折りたたむ",
"Export": "書き出し",
"Export Album with Preset": "プリセットでアルバムを書き出す",
"Export Album…": "アルバムを書き出す…",
"Export Group…": "グループを書き出す…",
"Export Now": "今すぐ書き出す",
"Export Point Curve Presets": "ポイントカーブのプリセットを書き出す",
"Export Point Curve Presets…": "ポイントカーブのプリセットを書き出す…",
"Export Presets": "プリセットを書き出す",
"Export Presets…": "プリセットを書き出す…",
"Export User Presets…": "ユーザープリセットを書き出す…",
"Export stopped unexpectedly": "書き出しが予期せず停止しました",
"Export with Preset": "プリセットで書き出す",
"Export with Previous": "前回の設定で書き出す",
"Exporting": "書き出し中",
@@ -283,17 +441,222 @@
"Exposure (shutter · aperture · ISO)": "露出(シャッター速度・絞り・ISO)",
"Extended Description": "詳しい説明",
"External Editor": "外部エディター",
"Face Boxes": "顔の枠",
"Face boxes": "顔の枠",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroomなどのアプリが XMP に書き込んだ顔の名前がここに表示されます",
"Faded Matte": "フェードマット",
"Faded Slide": "フェードスライド",
"Faded Urban": "フェードアーバン",
"Favorite Preset": "プリセットをお気に入りに追加",
"Favorite Profile": "プロファイルをお気に入りに追加",
"Favorites": "お気に入り",
"Feather": "ぼかし",
"File": "ファイル",
"File & date": "ファイルと日付",
"File Format": "ファイル形式",
"File Name": "ファイル名",
"File Path": "ファイルの場所",
"File Size": "ファイルサイズ",
"File Type": "ファイルの種類",
"Filename": "ファイル名",
"Fill": "画面いっぱい",
"Film": "フィルム",
"Filmstrip (/)": "フィルムストリップ(/)",
"Find Missing Photos": "見つからない写真を探す",
"First": "最初",
"First commit": "最初のコミット",
"Five stars": "星5つ",
"Flagged as Pick": "採用フラグ付き",
"Flagged as Reject": "不採用フラグ付き",
"Focal Length": "焦点距離",
"Focus (assisted culling)": "ピント(選別補助)",
"Food": "料理",
"Forget Unchanged Local Photos": "変更のないローカル写真を除外",
"Frame time": "フレーム時間",
"Frame update": "フレーム更新",
"Fresh & Bright": "フレッシュ&ブライト",
"GPU Rendering": "GPU描画",
"GPU rendering": "GPU描画",
"Generated demo photo": "生成したデモ写真",
"Get Develop Settings": "現像設定を取得",
"Go to History Step": "履歴の手順へ移動",
"Golden Glow": "ゴールデングロー",
"Golden Hour": "ゴールデンアワー",
"Grab bag": "名前の一覧",
"Gritty Street": "ざらついたストリート",
"Group by Date: Automatic": "日付でグループ化:自動",
"Group name": "グループ名",
"Guided Upright Guides": "ガイド付き遠近補正のガイド",
"HDR Merge Preview": "HDR合成のプレビュー",
"HDR Panorama Merge Preview": "HDRパノラマ合成のプレビュー",
"HDR Panorama with Last Settings": "前回の設定でHDRパノラマ合成",
"HDR Panoramas": "HDRパノラマ",
"HDR panorama": "HDRパノラマ",
"HDR panoramas": "HDRパノラマ",
"HDR with Last Settings": "前回の設定でHDR合成",
"Has Edits": "編集あり",
"Has GPS": "GPS情報あり",
"High Contrast B&W": "ハイコントラスト白黒",
"Home": "ホーム",
"Hours": "時間",
"ISO Speed": "ISO感度",
"ISO speed": "ISO感度",
"Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…",
"Import Profiles": "プロファイルを読み込む",
"Import as new": "新規として読み込む",
"Imported": "読み込み済み",
"Increase Brush Feather": "ブラシのぼかしを大きく",
"Increase Brush Size": "ブラシのサイズを大きく",
"Info Overlay Off": "情報オーバーレイなし",
"Info Overlay: Exposure": "情報オーバーレイ:露出",
"Info Overlay: File": "情報オーバーレイ:ファイル",
"Insert at the cursor": "カーソル位置に挿入",
"Inspect Photo": "写真を調べる",
"Instant ’70s": "インスタント’70s",
"Invert Component": "構成要素を反転",
"JPEG (Large)": "JPEG(大)",
"JPEG (Small)": "JPEG(小)",
"Join the ArtCraft Discord": "ArtCraftのDiscordに参加",
"Join the ArtCraft Discord…": "ArtCraftのDiscordに参加…",
"Join the ArtCraft community on Discord": "DiscordのArtCraftコミュニティに参加",
"Keywords (K)": "キーワード(K)",
"Label": "ラベル",
"Label Set": "ラベルセット",
"Last GPU fallback": "直近のGPUフォールバック",
"Last commit": "最後のコミット",
"Last loupe render": "直近のルーペ描画",
"Lens Blur": "レンズぼかし",
"Library Info": "ライブラリ情報",
"Library Preferences": "ライブラリの環境設定",
"Library State": "ライブラリの状態",
"Library saved": "ライブラリを保存しました",
"Licence:": "ライセンス:",
"Line delta": "行数の増減",
"Linear": "リニア",
"Lines +/−": "行数 +/−",
"Lines added": "追加した行数",
"Lines deleted": "削除した行数",
"List Albums": "アルバムを一覧表示",
"List Develop Controls": "現像コントロールを一覧表示",
"List History": "履歴を一覧表示",
"List Presets": "プリセットを一覧表示",
"List Profiles": "プロファイルを一覧表示",
"Locate Photo": "写真の場所を指定",
"Lock": "ロック",
"Lock Crop Aspect": "切り抜きの縦横比を固定",
"Locked": "ロック中",
"Lush Greens": "鮮やかな緑",
"Mask Adjustment": "マスクの調整",
"Match Total Exposures": "露出の合計を一致",
"Matte Soft": "ソフトマット",
"Medium Contrast": "コントラスト(中)",
"Memory Budget": "メモリ割り当て",
"Memory Usage": "メモリ使用量",
"Memory budget": "メモリ割り当て",
"Merged": "合成済み",
"Merged PRs": "マージしたPR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "LightCraftではなくMetaの利用規約です。ダウンロードすると規約に同意したことになります",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "メタデータがない場合({camera}、{title}…)はその部分が空になり、結果が空の名前は元の名前のままです",
"Misty Morning": "霧の朝",
"Model": "モデル",
"Models": "モデル",
"Mono Red Filter": "モノクロ(レッドフィルター)",
"Mono Rich": "リッチモノクロ",
"Mono Soft": "ソフトモノクロ",
"Moody": "ムーディー",
"Moody Portrait": "ムーディーポートレート",
"Move Album": "アルバムを移動",
"Move Down": "下へ移動",
"Move Folder": "フォルダを移動",
"Move Mask": "マスクを移動",
"Move Preset to Group": "プリセットをグループに移動",
"Move Preset to New Group": "プリセットを新しいグループに移動",
"Move Up": "上へ移動",
"Move in Stack": "スタック内で移動",
"Muted": "落ち着いた色",
"Muted Film": "ミューテッドフィルム",
"My Photos Panel": "「マイフォト」パネル",
"Name (A–Z)": "名前(A–Z)",
"Name of the folder the original is in": "元の写真があるフォルダの名前",
"Named People": "名前付きの人物",
"Neon Dusk": "ネオンダスク",
"Neon Night": "ネオンナイト",
"Nested group": "入れ子のグループ",
"New Album": "新しいアルバム",
"New time": "新しい日時",
"Next Photo": "次の写真",
"Night Blue": "ナイトブルー",
"No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません",
"No model credits were built into this copy.": "このビルドにはモデルのクレジットが含まれていません",
"No named people in these photos": "これらの写真に名前付きの人物はいません",
"No named people yet": "名前付きの人物はまだいません",
"Not Now": "後で",
"Nudge Develop Value": "現像の値を微調整",
"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.": "オブジェクトマスクと説明マスクには、Metaのセグメンテーションモデル SAM 3 を使います。SAM 3 はLightCraftの一部ではなく、ほかの機能はすべてなしで動作します",
"October 2026": "2026年10月",
"Only": "次のみ:",
"Optimize Library": "ライブラリを最適化",
"Original + Settings": "元画像+設定",
"Original extension without the dot": "元の拡張子(ドットなし)",
"Original file name without its extension": "拡張子を除いた元のファイル名",
"Panorama": "パノラマ",
"Panorama Merge Preview": "パノラマ合成のプレビュー",
"Panorama with Last Settings": "前回の設定でパノラマ合成",
"Panoramas": "パノラマ",
"Paste Settings": "設定を貼り付け",
"Paste Settings from Previous": "前の写真の設定を貼り付け",
"Paste from Previous": "前の写真から貼り付け",
"Pastel Haze": "パステルヘイズ",
"People": "人物",
"Person": "人物",
"Pet Eye Catchlight": "ペットの目のキャッチライト",
"Photos per bracket (0 = auto)": "ブラケットあたりの枚数(0 = 自動)",
"Pick Flag": "採用フラグ",
"Pictures": "ピクチャ",
"Prepare AI Masks": "AIマスクを準備",
"Presets and profiles": "プリセットとプロファイル",
"Preview Build Progress": "プレビュー作成の進行状況",
"Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中",
"Previous Photo": "前の写真",
"Profile Browser": "プロファイルブラウザー",
"Profile Menu": "プロファイルメニュー",
"Proof copy created": "校正用コピーを作成しました",
"Query Photos": "写真を照会",
"Quiet Green": "クワイエットグリーン",
"Quit Anyway": "終了する",
"Quit with unsaved changes?": "保存されていない変更があります。終了しますか?",
"RAW decoding": "RAWデコード",
"Radial": "円形",
"Random": "ランダム",
"Rating cleared": "評価を解除しました",
"Raw": "RAW",
"Read the SAM License": "SAMライセンスを読む",
"Real name": "本名",
"Recent": "最近使用",
"Recent Keywords": "最近使用したキーワード",
"Refine": "微調整",
"Refine AI Mask Detail": "AIマスクの細部を微調整",
"Refine Edges": "境界を微調整",
"Refine Mask Edges": "マスクの境界を微調整",
"Refine Saturation": "彩度を微調整",
"Refining the mask's detail…": "マスクの細部を微調整中…",
"Refresh Source": "コピー元を更新",
"Reject": "不採用",
"Reliability": "信頼性",
"Relink Photo": "写真の参照先を再設定",
"Reload Changed Files": "変更されたファイルを再読み込み",
"Remove Face Box": "顔の枠を除去",
"Remove a filter above, or choose Clear all": "上のフィルターを解除するか、「すべて解除」を選択してください",
"Remove from Target Album": "追加先アルバムから除去",
"Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 ▸ 取り消す」で元に戻せます)",
"Rename Component": "構成要素の名前を変更",
"Rename Folder": "フォルダ名を変更",
"Rename Mask": "マスク名を変更",
"Rename Preset": "プリセット名を変更",
"Rename Preview": "名前変更のプレビュー",
"Rename Template Tags": "名前変更テンプレートのタグ",
"Reorder Masks": "マスクを並べ替え",
"Reshuffle": "シャッフルし直す",
"File name": "ファイル名",
"Fill Edges": "境界を補完",
@@ -720,67 +1083,105 @@
"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)": "すべての編集をリセット(⌘⇧R)",
"Resize Face Box": "顔の枠のサイズを変更",
"Resize to": "サイズ変更",
"Resolution (ppi)": "解像度(ppi)",
"Restore": "復元",
"Restore Version": "バージョンを復元",
"Restore keeps their edits; Import as new deletes the old record first.": "復元すると編集内容が残ります。新規として読み込むと、先に古い記録を削除します。",
"Restore them": "復元する",
"Retouch": "レタッチ",
"Reverse the order": "逆順にする",
"Review": "レビュー",
"Review Import": "読み込み内容を確認",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "写真を右クリックして参照写真に設定し、「表示 → 参照表示」(⇧R)を選択してください",
"Right-click a preset to delete it": "プリセットを右クリックして削除",
"Rights Usage Terms": "利用条件",
"Rotate": "回転",
"Rotate Crop": "切り抜きを回転",
"Rotate Crop Aspect": "切り抜きの縦横比を回転",
"Rotate Left": "左に回転",
"Rotate Right": "右に回転",
"Roughness": "粗さ",
"Roundness": "丸み",
"Rules": "条件",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3のダウンロードを停止しました。次回は中断した位置から再開します",
"Same as raw default": "RAWの初期設定と同じ",
"Sample Mask Color": "マスクの色をサンプル",
"Sample a colour on the photo": "写真から色を選択",
"Saturation": "彩度",
"Saturation Range": "彩度の範囲",
"Save": "保存",
"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 set": "セットとして保存",
"Save preset": "プリセットを保存",
"Scale": "拡大率",
"Scan failed": "スキャンに失敗しました",
"Scanning": "検索中",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "評価結果は写真に保存されます。ピント評価や類似写真内のベストショットで絞り込み・スマートアルバム作成ができます",
"Screen": "画面用",
"Search": "検索",
"Search Photos": "写真を検索",
"Seasons": "四季",
"Second Window": "第2ウィンドウ",
"Select": "選択",
"Select All": "すべて選択",
"Select Luminance Range": "輝度範囲を選択",
"Select Mask": "マスクを選択",
"Select Photos": "写真を選択",
"Select Spot": "スポットを選択",
"Select a photo to edit": "編集する写真を選択してください",
"Select by": "条件で選択",
"Select by Flag, Rating or Label": "フラグ・評価・ラベルで選択",
"Select photos, then choose View → Survey (N)": "写真を選び、「表示 → 一覧比較」(N)を選択してください",
"Select two photos, then choose View → Compare (Shift+C)": "2枚を選び、「表示 → 比較」(⇧C)を選択してください",
"Selected": "選択中",
"Selecting…": "選択中…",
"Selenium Tone": "セレン調色",
"Send Feedback…": "フィードバックを送る…",
"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 Image Zoom and Pan": "画像のズームとパンを設定",
"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": "写真の色からミックスを自動設定",
"Settings": "設定",
"Settings file problem": "設定ファイルの問題",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新しく読み込むRAW写真の初期設定です。既存の写真には影響しません",
"Settings pasted": "設定を貼り付けました",
"Settings to include": "含める設定",
"Settings…": "設定…",
"Shade": "日陰",
@@ -794,7 +1195,10 @@
"Sharpen": "シャープ処理",
"Sharpening": "シャープ",
"Sharpness": "シャープネス",
"Shift": "ずらす量",
"Shift by": "時刻をずらす",
"Short Edge": "短辺",
"Show": "表示",
"Show All": "すべて表示",
"Show Clipping": "白飛び・黒つぶれを表示",
"Show Deghost Overlay": "ゴースト除去範囲を表示",
@@ -820,6 +1224,7 @@
"Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示",
"Show the Navigator while zoomed in": "拡大時にナビゲーターを表示",
"Show the template tags; click one to insert it": "テンプレートのタグを表示(クリックで挿入)",
"Show/Hide Mask": "マスクの表示/非表示",
"Show/hide My Photos panel": "マイフォトパネルを表示/非表示",
"Shutter speed": "シャッター速度",
"Sidecar names": "サイドカーファイル名",
@@ -828,16 +1233,30 @@
"Size limit": "サイズの上限",
"Skip": "スキップ",
"Sky": "空",
"Slideshow": "スライドショー",
"Slideshow paused": "スライドショーを一時停止しました",
"Slideshow resumed": "スライドショーを再開しました",
"Slideshow · Space pauses · Esc ends": "スライドショー · スペースで一時停止 · Escで終了",
"Smart Album": "スマートアルバム",
"Smart Album Rule Fields": "スマートアルバムの条件フィールド",
"Smart Preview Status": "スマートプレビューの状態",
"Smart Previews Location": "スマートプレビューの保存場所",
"Smart preview available": "スマートプレビューあり",
"Smart previews": "スマートプレビュー",
"Smart previews folder changed": "スマートプレビューのフォルダを変更しました",
"Smoothness": "滑らかさ",
"Soft B&W": "ソフト白黒",
"Soft Pastel": "ソフトパステル",
"Soft Proofing": "ソフト校正",
"Soft Skin": "ソフトスキン",
"Sort": "並べ替え",
"Sort by": "並べ替え",
"Source": "読み込み元",
"Source code on GitHub": "GitHubのソースコード",
"Spherical": "球面",
"Spiral": "螺旋",
"Split Stack": "スタックを分割",
"Spring Fresh": "スプリングフレッシュ",
"Square Grid": "正方形グリッド",
"Square Grid (G toggles)": "正方形グリッド(Gで切り替え)",
"Square Grid: show file names and formats": "正方形グリッド:ファイル名と形式を表示",
@@ -845,8 +1264,10 @@
"Stack photos taken within this time of each other:": "この時間内に撮影した写真をスタックにまとめる:",
"Stacks": "スタック",
"Standard": "標準",
"Star rating (0–5)": "星の評価(0–5)",
"Start at": "開始番号",
"Start number": "開始番号",
"Starting": "開始中",
"Startup": "起動時",
"State / Province": "都道府県・州",
"Stop": "停止",
@@ -855,66 +1276,97 @@
"Stopping…": "停止中…",
"Straighten": "角度補正",
"Straighten Tool": "角度補正ツール",
"Strong Contrast": "強いコントラスト",
"Style": "スタイル",
"Subfolder": "サブフォルダ",
"Subfolder name": "サブフォルダ名",
"Subject": "被写体",
"Subtract": "減算",
"Subtract from this mask": "このマスクから減算",
"Suggestions": "候補",
"Summer Haze": "サマーヘイズ",
"Survey": "一覧比較",
"Survey (N)": "一覧比較(N)",
"Swap": "入れ替え",
"Swap Aspect (X)": "縦横比を入れ替え(X)",
"Swap Before and After": "編集前と編集後を入れ替え",
"Swap Before and After Settings": "編集前と編集後の設定を入れ替える",
"Swap Compare Photos": "比較写真を入れ替え",
"Sync Settings": "設定を同期",
"System": "システム",
"System Info": "システム情報",
"System Info…": "システム情報…",
"System default": "システム標準",
"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…を付けます",
"Targeted Adjustment": "ターゲット調整",
"Targeted adjustment: drag up/down on the photo to adjust the colours there": "写真の上を上下にドラッグして、その部分の色を調整",
"Targeted adjustment: drag up/down on the photo to adjust the curve there": "写真の上を上下にドラッグして、その部分のカーブを調整",
"Teal & Amber": "ティール&アンバー",
"Teal & Orange": "ティール&オレンジ",
"Temp": "色温度",
"Temperature": "色温度",
"Template": "テンプレート",
"Text": "文字",
"Texture": "テクスチャ",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3モデルはインストール済みです。オブジェクトマスクと説明マスクを使用できます",
"The download didn't work:": "ダウンロードに失敗しました:",
"The number at the end of the original name (IMG_0042 → 0042)": "元の名前の末尾の番号(IMG_0042 → 0042)",
"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": "この写真に適用されている設定",
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
"Thirds": "三分割",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "このビルドにはモデルのダウンロード元がまだありません。上のフォルダにファイルを手動で置いてください(docs/ai-masks.md を参照)",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "このフォルダにアクセスできません。ドライブを接続するか別のフォルダを選ぶまで、スマートプレビューは作成・使用されません",
"This photo can't be opened": "この写真を開けません",
"Threshold": "しきい値",
"Thumbnail cache": "サムネイルキャッシュ",
"Time zone": "タイムゾーン",
"Tint": "色かぶり補正",
"Title": "タイトル",
"Title metadata": "メタデータのタイトル",
"Toggle Keyword from Set": "セットのキーワードを付け外し",
"Toggle Mask": "マスクを切り替え",
"Toggle Section": "セクションを切り替え",
"Toggle Zoom": "ズームを切り替え",
"Tone Curve": "トーンカーブ",
"Tools": "ツール",
"Top Level": "最上位",
"Transfer": "転送",
"Treatment": "処理",
"Triangle": "三角形",
"Try Saving Again": "もう一度保存する",
"Tungsten": "白熱灯",
"Turn Off": "無効にする",
"Turn on Include subfolders, or pick another folder under Local": "サブフォルダを含める設定を有効にするか、ローカルから別のフォルダを選択してください",
"Uncheck All": "すべて選択解除",
"Undo": "取り消す",
"Unedited": "未編集",
"Unedited raws": "未編集のRAW",
"Unflag": "フラグを解除",
"Unflagged": "フラグなし",
"Ungroup Stack": "スタックを解除",
"Unknown tags stay as typed — check the preview for a {typo}.": "不明なタグは入力したまま残ります。{typo} がないかプレビューで確認してください",
"Unrated": "未評価",
"Unreadable": "読み込み不可",
"Update": "更新",
"Update Mask Shape": "マスクの形状を更新",
"Update Preset with Current Settings": "現在の設定でプリセットを更新",
"Update Rules from Current Filter": "現在のフィルターから条件を更新",
"Update Version": "バージョンを更新",
"Update Version with Current Settings": "現在の設定でバージョンを更新",
"Update with Current Settings": "現在の設定で更新",
"Updated the edits saved in the external editor": "外部エディターで保存した編集内容を更新しました",
"Upright": "遠近補正",
"Urban": "アーバン",
"Use Keyword Set": "キーワードセットを使用",
"Use Library Folder": "ライブラリのフォルダを使用",
"Use camera-specific defaults": "カメラごとの初期設定を使用",
"Use the GPU for rendering": "描画にGPUを使用",
"User Presets": "ユーザープリセット",
"Username": "ユーザー名",
"Value": "値",
"Variance": "ばらつき",
"Version": "バージョン",
@@ -925,12 +1377,21 @@
"Vibrance": "自然な彩度",
"Videos": "動画",
"View": "表示",
"Viewing and sharing": "表示と共有",
"Vignette": "周辺光量補正",
"Vignetting": "周辺光量補正",
"Vintage": "ヴィンテージ",
"Virtual Copy": "仮想コピー",
"Visible": "表示中",
"Visualize Spots": "スポットを表示",
"Visualize Spots (A)": "スポットを表示(A)",
"Visualize range": "範囲を表示",
"Vivid": "鮮やか",
"Vivid Pop": "ビビッドポップ",
"Warm Film": "ウォームフィルム",
"Warm Glow": "ウォームグロー",
"Warm Print": "ウォームプリント",
"Warm Table": "ウォームテーブル",
"Watched folder": "監視フォルダ",
"Watermark": "透かし",
"What's New": "新着情報",
@@ -946,6 +1407,8 @@
"Will be replaced": "置き換えられます",
"Will be restored": "復元されます",
"Window": "ウィンドウ",
"Winter Blue": "ウィンターブルー",
"XMP Sidecar Preferences": "XMPサイドカーファイルの環境設定",
"XMP sidecars": "XMPサイドカーファイル",
"Year": "年",
"Yellow": "イエロー",
@@ -953,15 +1416,58 @@
"Yellow Luminance": "イエローの輝度",
"Yellow Saturation": "イエローの彩度",
"Yes": "はい",
"You can close this: the download continues, and resumes if interrupted.": "このウィンドウは閉じてもかまいません。ダウンロードは続行され、中断しても再開します",
"Your Name": "あなたの名前",
"Zoom": "拡大/縮小",
"Zoom 100%": "100%表示",
"Zoom In": "拡大",
"Zoom Out": "縮小",
"Zoom to Fit": "全体を表示",
"a literal %": "% 記号そのもの",
"a removed photo": "削除された写真",
"all": "すべて",
"and": "と",
"any": "いずれか",
"are empty": "が空",
"aren't empty": "が空でない",
"blue": "ブルー",
"comma, separated": "カンマ区切り",
"contains": "を含む",
"copyrighted": "著作権あり",
"day 01–31": "日 01–31",
"days": "日",
"discarding smart previews": "スマートプレビューを破棄中",
"doesn't contain": "を含まない",
"e.g. sky · the red car · car, road": "例(英語):sky · the red car · car, road",
"ends with": "で終わる",
"false": "いいえ",
"green": "グリーン",
"hour 00–23": "時 00–23",
"hours": "時間",
"image": "画像",
"is": "が次と一致",
"is <": "が次より小さい",
"is >": "が次より大きい",
"is after": "が次より後",
"is before": "が次より前",
"is between": "が次の範囲内",
"is empty": "が空",
"is in the last": "が次の期間内:過去",
"is unknown": "が不明",
"is ≤": "が次以下",
"is ≥": "が次以上",
"isn't": "が次と一致しない",
"isn't empty": "が空でない",
"isn't in the last": "が次の期間外:過去",
"keep names — or e.g. {date}_{seq:3}": "名前を変更しない(例:{date}_{seq:3})",
"latitude, longitude": "緯度, 経度",
"memory only": "メモリ上のみ",
"minute": "分",
"month 01–12": "月 01–12",
"months": "か月",
"none": "なし",
"none (CPU rendering)": "なし(CPU描画)",
"none of": "次のいずれにも該当しない",
"not available here": "ここでは利用できません",
"of the following rules:": "以下の条件に一致:",
"Effect": "効果",
@@ -1055,5 +1561,36 @@
"Expand": "展開",
"Remove Disk from Library": "ディスクをライブラリから除去",
"All the photos imported from this disk are included.": "このディスクから読み込んだすべての写真が対象です",
"That includes the photos in the folders inside it.": "内側のフォルダにある写真も含まれます"
"That includes the photos in the folders inside it.": "内側のフォルダにある写真も含まれます",
"or": "または",
"pick": "採用",
"previews": "プレビュー",
"publicDomain": "パブリックドメイン",
"purple": "パープル",
"raw": "RAW",
"red": "レッド",
"reject": "不採用",
"saved in": "保存先",
"second": "秒",
"smart previews": "スマートプレビュー",
"starts with": "で始まる",
"the photos in view": "表示中の写真",
"the selected photos": "選択した写真",
"this photo": "この写真",
"true": "はい",
"unknown": "不明",
"video": "動画",
"weeks": "週",
"year, 2 digits": "年(2桁)",
"year, 4 digits": "年(4桁)",
"years": "年",
"yellow": "イエロー",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} は撮影日時です。撮影日時がない写真には読み込んだ日時を使います",
"© 2026 Your Name": "© 2026 あなたの名前",
"© Your Name": "© あなたの名前",
"− harder border · + softer border": "− 境界を硬く · + 境界を柔らかく",
"≤ at most": "≤ 以下",
"≥ at least": "≥ 以上",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
}
+55 -1
View File
@@ -122,5 +122,59 @@
"{} spot(s) on this photo": "{} ponto(s) nesta foto",
"· {dups} already in the library": "· {dups} já na biblioteca",
"· {n} photo{}": "· {n} foto{:.0}",
"… and {more} more": "… e mais {more}"
"… and {more} more": "… e mais {more}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} com vários arquivos parecidos: use Localizar)",
" · {:.0}% clipped": " · {:.0}% recortado",
" · {failed} couldn't be rendered": " · {failed} não puderam ser renderizadas",
" · {} original{} left at the source": " · originais mantidos na origem: {}{:.0}",
" — not carried over: {}{}": " — não transferido: {}{}",
"+{} more": "+{} a mais",
"; {kept} already had a location": "; {kept} já tinham localização",
"; {outside} outside its time range": "; {outside} fora do intervalo de tempo",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} linhas (Δ {}), +{} / −{} arquivos binários, {} – {}",
"Added {what} to {name}": "{what} adicionado a {name}",
"Auto Import: added {} photo{}": "Importação automática: {} foto{1} adicionada{1}",
"Building previews for {} photos…": "Gerando prévias de {} fotos…",
"Couldn't open the editor: {e}": "Não foi possível abrir o editor: {e}",
"Creates {} stacks from {} of {scope}": "Cria {} pilhas a partir de {} de {scope}",
"Done ({what}): {done} photo{plural}": "Concluído ({what}): {done} foto{plural}",
"Focus {:.0}": "Foco {:.0}",
"Folder “{}” (and its subfolders)": "Pasta “{}” (e suas subpastas)",
"Found {n} missing photo{}; {left} still missing{unsure}": "{n} foto{0} ausente{0} encontrada{0}; {left} ainda ausente(s){unsure}",
"Import failed: {e}": "Falha na importação: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} perfil(is) importado(s){:.0} (Navegador de perfis ▸ seus grupos)",
"Insert at the cursor (also written {})": "Inserir no cursor (também escrito {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fontes: {} (OFL). Ícones: originais.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Aplicando “{k}”: clique nas fotos para adicionar ou remover · Esc para",
"Previews ready for {done} photo{plural}": "Prévias prontas para {done} foto{plural}",
"Removed {what} from {name}": "{what} removido de {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renomeia “{from}” em {n} foto{} (incluindo as palavras-chave abaixo dela). Use | para níveis, p. ex. Viagem|Itália. Um nome já existente mescla os dois.",
"Replace {} with:": "Substituir {} por:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Avalia o foco e a exposição de {scope} e encontra fotos semelhantes tiradas com segundos de diferença (sequências).",
"Tagged {n} photo{} from the tracklog": "{n} foto{0} marcada{0} pelo tracklog",
"The SAM 3 download failed: {e}": "Falha no download do SAM 3: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Marcador(es) desconhecido(s){s:.0} {} permanece(m) como digitado(s){:.0} (veja Marcadores)",
"Virtual copy “{name}” of {of}": "Cópia virtual “{name}” de {of}",
"Working on {what} for {} photos…": "Processando {what} em {} fotos…",
"{label} {index} of {n}": "{label} {index} de {n}",
"{name} opened for editing; it is stacked with the original": "{name} aberta para edição; está empilhada com o original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} alteração(ões){0:.0} não puderam ser gravadas no disco: {e}\nSe você sair agora, elas serão perdidas.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} alteração(ões){:.0} salva(s) na memória, mas não gravada(s) no disco: {e} — o LightCraft tentará de novo",
"{pid} (missing)": "{pid} (ausente)",
"{{date:…}} directives: {}": "Diretivas de {{date:…}}: {}",
"{} contributors · {} commits": "{} colaboradores · {} commits",
"{} failed": "Falha: {}",
"{} item{}": "{} item(ns){:.0}",
"{} photos · exposures {} EV": "{} fotos · exposições {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} amostra{}",
"{} sec": "{} s",
"{} — show their photos": "{} — mostrar as fotos desta pessoa",
"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.": "O LightCraft ainda não consegue decodificar esta variante RAW ({}). Você está editando a prévia JPEG incorporada da câmera, que já inclui o estilo de imagem da câmera (p. ex. Monocromático) e o balanço de branco.",
"Filtered: {label}": "Filtrado: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} foto{0} semelhante{0} · Exibir ▸ Limpar filtros para ver todas",
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) novamente",
"{} label (click more labels to show any of them)": "Rótulo {} (clique em mais rótulos para mostrar qualquer um deles)",
", {} star{}": ", {} estrela{}",
", {} label": ", rótulo {}"
}
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
{
" ({unsure} with several look-alike files: use Locate)": " (из них с несколькими похожими файлами: {unsure} — используйте «Найти фото»)",
" ({} in Recently Deleted)": " ({} в «Недавно удалённых»)",
" · {:.0}% clipped": " · обрезано: {:.0}%",
" · {failed} couldn't be rendered": " · не удалось отрисовать: {failed}",
" · {skipped} skipped (file exists)": " · пропущено: {skipped} (файл существует)",
" · {} duplicate{} skipped": " · пропущено дубликатов: {}{:.0}",
" · {} failed: {}": " · не удалось: {}; ошибка: {}",
" · {} moved": " · перемещено: {}",
" · {} not readable": " · не читается: {}",
" · {} original{} left at the source": " · оригиналов оставлено в источнике: {}{:.0}",
" · {} restored from Recently Deleted": " · восстановлено из «Недавно удалённых»: {}",
" — not carried over: {}{}": " — не перенесено: {}{}",
"+{} more": "+{} ещё",
", {failed} file{} not readable": ", не читается файлов: {failed}{:.0}",
", {} label": ", метка: {}",
", {} star{}": ", звёзд: {}{:.0}",
"; {kept} already had a location": "; уже имели местоположение: {kept}",
"; {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} photo{} to My Photos": "В «Мои фото» добавлено фото: {n}{:.0}",
"Added {n} photo{} to “{}”": "В «{1}» добавлено фото: {n}{0:.0}",
"Added {what} to {name}": "{what}: добавлено в {name}",
"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)": "Автоматически ({} МБ)",
"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 {n} Virtual Copies": "Создать виртуальные копии: {n}",
"Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})",
"Decoder: {reason}": "Декодер: {reason}",
"Delete \"{label}\"": "Удалить «{label}»",
"Delete \"{label}\" (and the mask)": "Удалить «{label}» (и маску)",
"Delete {n} Photos": "Удалить фото: {n}",
"Delete “{current}”": "Удалить «{current}»",
"Discard Smart Previews ({scope})": "Удалить смарт-просмотры ({scope})",
"Done ({what}): {done} photo{plural}": "Готово ({what}): фото: {done}{plural:.0}",
"Duplicate \"{label}\"": "Дублировать «{label}»",
"Example: {example}": "Пример: {example}",
"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}",
"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}",
"Insert at the cursor (also written {})": "Вставить в позицию курсора (также пишется как {})",
"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-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
"Matches: {}": "Совпадения: {}",
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "Переместить {what} в «Недавно удалённые»?",
"Moved; {} photo(s) relinked": "Перемещено; перепривязано фото: {}",
"Named ({named_n})": "Именованные ({named_n})",
"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 · {}": "Пробный просмотр · {}",
"Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}",
"Quick Develop: {name}": "Быстрая коррекция: {name}",
"Rated {}": "Оценка: {}",
"Reading photos… {done} of {total}": "Чтение фото… {done} из {total}",
"Reading photos… {} of {}": "Чтение фото… {} из {}",
"Redo {}": "Повторить {}",
"Remove “{name}” and its {count} photo{} from the library?": "Удалить «{name}» и фото ({count}){:.0} из библиотеки?",
"Remove “{}” from Favorites": "Убрать «{}» из избранного",
"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 {} Photo{}": "Восстановить фото: {}{:.0}",
"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} из {}",
"Smart album: {}": "Смарт-альбом: {}",
"Tagged {n} photo{} from the tracklog": "Привязано по треку фото: {n}{:.0}",
"The SAM 3 download failed: {e}": "Не удалось загрузить SAM 3: {e}",
"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.": "Версия {} — независимо разработанная библиотека фото и RAW-конвертер на чистом Rust.",
"Version: {}": "Версия: {}",
"Virtual copy “{name}” of {of}": "Виртуальная копия «{name}» от {of}",
"Working on {what} for {} photos…": "{what}: обработка фото ({})…",
"none (CPU rendering): {why}": "нет (рендеринг на CPU): {why}",
"the {n} selected photos": "выбранные {n} фото",
"the {} photos in view": "все {} фото в представлении",
"these {count} photos": "эти {count} фото",
"{count} smart preview{} · {:.1} MB": "Смарт-просмотров: {count}{:.0} · {:.1} МБ",
"{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}",
"{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} — double-click to reset it": "{name} — двойной щелчок для сброса",
"{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}): {e}\nLightCraft повторяет попытку автоматически; при выходе сейчас они будут потеряны.{:.0}{:.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} photos": "Фото: {n}",
"{n} photos in {} are hidden by the filters above": "Фильтры выше скрывают фото в разделе «{}»: {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} · Вид → Сбросить фильтры, чтобы увидеть все",
"{pid} (missing)": "{pid} (отсутствует)",
"{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}",
"{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 failed: {e}": "{what}: ошибка слияния: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Директивы {{date:…}}: {}",
"{} 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 — проведите вдоль линий, которые должны быть вертикальными или горизонтальными.",
"{} photos": "Фото: {}",
"{} photos · exposures {} EV": "Фото: {} · экспозиции {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Не читается фото: {}{:.0}",
"{} sample{}": "Образцов: {}{:.0}",
"{} sec": "{} с",
"{} spot(s) on this photo": "Пятен на этом фото: {}",
"{} — show their photos": "{} — показать фото этого человека",
"· {dups} already in the library": "· уже в библиотеке: {dups}",
"· {n} photo{}": "· фото: {n}{:.0}",
"… and {more} more": "… и ещё {more}"
}
File diff suppressed because it is too large Load Diff
+55 -1
View File
@@ -122,5 +122,59 @@
"{month} {year}": "{year}年{month}月",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.0}"
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(其中 {unsure} 张有多个相似文件:请使用“定位”)",
" · {:.0}% clipped": " · {0:.0}% 剪切",
" · {failed} couldn't be rendered": " · {failed} 张无法渲染",
" · {} original{} left at the source": " · {0} 个原始文件留在了来源位置{1:.0}",
" — not carried over: {}{}": " — 未沿用:{0}{1}",
"+{} more": "+{0} 个",
"; {kept} already had a location": ";{kept} 张已有位置信息",
"; {outside} outside its time range": ";{outside} 张不在其时间范围内",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:{} 个 PR,{} 次提交,+{} / −{} 行(Δ {}),+{} / −{} 个二进制资源,{} – {}",
"Added {what} to {name}": "已将{what}添加到 {name}",
"Auto Import: added {} photo{}": "自动导入:已添加 {0} 张照片{1:.0}",
"Building previews for {} photos…": "正在为 {0} 张照片生成预览…",
"Couldn't open the editor: {e}": "无法打开编辑器:{e}",
"Creates {} stacks from {} of {scope}": "将从{scope}中的 {1} 张照片创建 {0} 个堆叠",
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 张照片{plural:.0}",
"Focus {:.0}": "对焦 {0:.0}",
"Folder “{}” (and its subfolders)": "文件夹“{0}”(含子文件夹)",
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 张缺失的照片{0:.0};仍有 {left} 张缺失{unsure}",
"Import failed: {e}": "导入失败:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已导入 {profiles} 个配置文件{0:.0}(配置文件浏览器 ▸ 各自的分组)",
"Insert at the cursor (also written {})": "在光标处插入(也可写作 {0})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字体:{0}(OFL)。图标:原创。",
"Painting “{k}”: click photos to add or remove it · Esc stops": "正在应用“{k}”:单击照片以添加或移除 · 按 Esc 停止",
"Previews ready for {done} photo{plural}": "已为 {done} 张照片生成预览{plural:.0}",
"Removed {what} from {name}": "已从 {name} 移除{what}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "在 {n} 张照片上重命名“{from}”{0:.0}(其下级关键字也一并处理)。用 | 分隔层级,例如 Travel|Italy。改为已有名称时,两者将合并。",
"Replace {} with:": "将 {0} 替换为:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "为{scope}评估对焦和曝光,并找出相隔几秒内拍摄的相似照片(连拍)。",
"Tagged {n} photo{} from the tracklog": "已根据轨迹日志标记 {n} 张照片{0:.0}",
"The SAM 3 download failed: {e}": "SAM 3 下载失败:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "未知标签{s:.0} {0} 将按原样保留{1:.0}(参见“标签”)",
"Virtual copy “{name}” of {of}": "{of} 的虚拟副本“{name}”",
"Working on {what} for {} photos…": "正在为 {0} 张照片处理{what}…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "已打开 {name} 进行编辑;它已与原始照片堆叠在一起",
"{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} — LightCraft will retry": "{n} 项更改{0:.0}已保存在内存中,但未写入磁盘:{e} — LightCraft 会重试",
"{pid} (missing)": "{pid}(缺失)",
"{{date:…}} directives: {}": "{{date:…}} 格式说明:{0}",
"{} contributors · {} commits": "{0} 位贡献者 · {1} 次提交",
"{} failed": "{0}失败",
"{} item{}": "{0} 项{1:.0}",
"{} photos · exposures {} EV": "{0} 张照片 · 曝光 {1} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{0} 张照片 · {1} · {2:.0}° × {3:.0}°",
"{} sample{}": "{0} 个样本{1:.0}",
"{} sec": "{0} 秒",
"{} — show their photos": "{0} — 显示其照片",
"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 变体({0})。你正在编辑相机内嵌的 JPEG 预览,其中已包含相机的照片风格(如单色)和白平衡。",
"Filtered: {label}": "已筛选:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 张相似照片{0:.0} · 选择“视图 ▸ 清除过滤器”可查看全部",
"Moved; {} photo(s) relinked": "已移动;已重新链接 {0} 张照片",
"{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)",
", {} star{}": ",{0} 星{1:.0}",
", {} label": ",{0}标签"
}
+320
View File
@@ -1,14 +1,31 @@
{
" (library default)": " (图库默认值)",
" (inverted)": " (已反相)",
" · best of its burst": " · 连拍中的最佳照片",
" · in a burst": " · 属于一组连拍",
"% of all commits": "占全部提交的百分比",
"+ Group": "+ 组",
"+ Rule": "+ 条件",
", picked": ",留用",
", rejected": ",排除",
"0: nothing is rejected.": "0:不排除任何照片。",
"1 day": "1 天",
"2026-04 or 2026-04-12": "2026-04 或 2026-04-12",
"= exactly": "= 等于",
"A blank template keeps the original names.": "模板留空则保留原始名称。",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)",
"A one-time download of about": "一次性下载,大小约",
"AI Mask Model Status": "AI 蒙版模型状态",
"About": "关于",
"About LightCraft": "关于 LightCraft",
"Active": "当前照片",
"Activity": "活动",
"Actual size": "实际大小",
"Add": "添加",
"Add Catchlight": "添加眼神光",
"Add Folder to Local": "将文件夹添加到本地",
"Add Mask": "添加蒙版",
"Add Object Click": "添加对象点击",
"Add Point Color Sample": "添加点颜色样本",
"Add Red Eye Correction": "添加红眼校正",
"Add Remove Spot": "添加污点去除",
@@ -21,9 +38,11 @@
"Add to": "添加到",
"Add to Album": "添加到相册",
"Add to Favorites": "添加到收藏夹",
"Add to Local": "添加到本地",
"Add to My Photos": "添加到我的照片",
"Add to Selected Photos": "添加到所选照片",
"Add to Target Album": "添加到目标相册",
"Add to this mask": "添加到此蒙版",
"Add/Subtract/Intersect Mask": "添加/减去/相交蒙版",
"Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。",
"Adding": "正在添加",
@@ -44,6 +63,11 @@
"Alt Text": "替代文本",
"Always": "始终",
"Amount": "数量",
"Analyzing the photo for AI masks…": "正在分析照片以生成 AI 蒙版…",
"Any Searchable Text": "任意可搜索文本",
"Any camera": "任意相机",
"Any keyword": "任意关键字",
"Any lens": "任意镜头",
"Aperture": "光圈",
"Application": "应用程序",
"Apply": "应用",
@@ -80,9 +104,11 @@
"Auto Import Now": "立即自动导入",
"Auto Import Settings": "自动导入设置",
"Auto Mask": "自动蒙版",
"Auto Select": "自动选择",
"Auto Settings": "自动设置",
"Auto Straighten": "自动拉直",
"Auto Sync": "自动同步",
"Auto settings applied": "已应用自动设置",
"Auto-Stack by Capture Time": "按拍摄时间自动堆叠",
"Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…",
"Auto-Tag Photos from Tracklog": "从轨迹日志自动标记照片",
@@ -110,6 +136,9 @@
"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": "黑色色阶",
@@ -140,19 +169,32 @@
"By Date": "按日期",
"By day (YYYY/YYYY-MM-DD)": "按日 (YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "按月 (YYYY/YYYY-MM)",
"CPU threads": "CPU 线程数",
"Calibration": "校准",
"Camera": "相机",
"Camera make and model": "相机品牌和型号",
"Cameras and Cards": "相机和存储卡",
"Cancel": "取消",
"Cancel AI Mask Model Download": "取消下载 AI 蒙版模型",
"Cancel Download": "取消下载",
"Cancel Interaction": "取消交互",
"Cancel Preview Build": "取消生成预览",
"Candidate": "候选",
"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…。",
"Check All": "全部选中",
"Check Output Path": "检查输出路径",
"Checking names…": "正在检查名称…",
"Choose Another Library…": "选择其他图库…",
"Choose Edit Settings to Copy": "选择要复制的编辑设置",
"Choose Edit Settings to Copy…": "选择要复制的编辑设置…",
@@ -177,8 +219,11 @@
"Click Zoom Ratio": "单击缩放比例",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。",
"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": "单击以恢复 · 双击以重命名",
"Clone": "仿制",
"Close": "关闭",
"Close Panel": "关闭面板",
@@ -187,7 +232,9 @@
"Close file": "关闭文件",
"Cloudy": "阴天",
"Collapse All Stacks": "折叠所有堆叠",
"Collapse Stack": "折叠堆叠",
"Color": "颜色",
"Color Label": "颜色标签",
"Color label cleared": "已清除颜色标签",
"Color Grading": "颜色分级",
"Color Label Names": "颜色标签名称",
@@ -198,9 +245,12 @@
"Color Range": "颜色范围",
"Color space": "色彩空间",
"Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)",
"Colors": "颜色",
"Combine files…": "合并文件…",
"Command Journal": "命令日志",
"Comments panel": "注释面板",
"Commits": "提交",
"Company": "公司",
"Compare": "比较",
"Compare (Shift+C)": "比较(Shift+C)",
"Compare Before and After": "比较修改前和修改后",
@@ -211,13 +261,16 @@
"Confirm before deleting": "删除前确认",
"Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认",
"Constrain Crop": "限制裁剪",
"Continue": "继续",
"Continue Without Saving": "不保存继续",
"Contrast": "对比度",
"Contributors": "贡献者",
"Convert to 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": "将历史记录步骤的设置复制到修改前",
@@ -252,16 +305,19 @@
"Create Version from Step": "从该步骤创建版本",
"Create Virtual Copy": "创建虚拟副本",
"Creator": "创建者",
"Creator metadata": "元数据中的创建者",
"Crisp Landscape": "清晰风光",
"Crisp Vista": "通透远景",
"Crop": "裁剪",
"Crop & Rotate": "裁剪和旋转",
"Crop & Rotate (C)": "裁剪和旋转(C)",
"Crop Aspect": "裁切长宽比",
"Crop, Rotate, Geometry": "裁剪、旋转、几何",
"Cross Process": "交叉冲洗",
"Culling": "挑片",
"Curve": "曲线",
"Custom": "自定义",
"Custom template…": "自定义模板…",
"Custom…": "自定义…",
"Cut": "剪切",
"Cut pages": "剪切页面",
@@ -274,6 +330,7 @@
"Dark & Moody": "暗调氛围",
"Darken": "压暗",
"Darks": "暗部",
"Date": "日期",
"Date Groups": "日期分组",
"Day": "日",
"Daylight": "日光",
@@ -288,6 +345,7 @@
"Delete Album": "删除相册",
"Delete All Masks": "删除所有蒙版",
"Delete Color Label Set": "删除颜色标签集",
"Delete Component": "删除组件",
"Delete Export Preset": "删除导出预设",
"Delete Filter Preset": "删除筛选器预设",
"Delete Imported Profile": "删除导入的配置文件",
@@ -298,9 +356,11 @@
"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": "删除版本",
@@ -311,27 +371,41 @@
"Density": "密度",
"Depth Range": "深度范围",
"Descending": "降序",
"Describe": "描述",
"Describe what to keep": "描述要保留的内容",
"Describe what to leave out": "描述要排除的内容",
"Describe what to select": "描述要选择的内容",
"Describe…": "描述…",
"Deselect": "取消选择",
"Deselect All": "取消全选",
"Desert Heat": "沙漠热浪",
"Desert Warmth": "沙漠暖意",
"Desktop": "桌面",
"Detail": "细节",
"Detail (D)": "细节 (D)",
"Details": "详细信息",
"Diagonal": "对角线",
"Discard Preview Cache": "丢弃预览缓存",
"Display P3": "Display P3",
"Display name": "显示名称",
"Display theme": "显示主题",
"Distortion": "扭曲",
"Document properties…": "文档属性…",
"Don't enlarge": "不放大",
"Done": "完成",
"Download": "下载",
"Download AI Mask Model": "下载 AI 蒙版模型",
"Download the SAM 3 Model?": "要下载 SAM 3 模型吗?",
"Downloads": "下载",
"Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正",
"Drag over an eye on the photo; the pupil inside is found automatically.": "在照片中的眼睛上拖动;会自动找到其中的瞳孔。",
"Draw Guides": "绘制参考线",
"Duplicate": "创建副本",
"Duplicate Component": "复制组件",
"Duplicate Mask": "复制蒙版",
"Duplicate and Invert Mask": "复制并反转蒙版",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每个 / 开始一层文件夹;标签按照片填充(缺少元数据的层级为“unknown”)。",
"Edge": "边缘",
"Edit": "编辑",
"Edit (E)": "编辑 (E)",
"Edit Capture Time": "编辑拍摄时间",
@@ -339,27 +413,36 @@
"Edit Color Label Names": "编辑颜色标签名称",
"Edit Color Label Names…": "编辑颜色标签名称…",
"Edit Copy for External Editor": "编辑外部编辑器副本",
"Edit Date": "编辑日期",
"Edit Info": "编辑信息",
"Edit Label Names": "编辑标签名称",
"Edit Label Names…": "编辑标签名称…",
"Edit Mask": "编辑蒙版",
"Edit Mask Component": "编辑蒙版组件",
"Edit Sections": "编辑板块",
"Edit Smart Album": "编辑智能相册",
"Edit Smart Album…": "编辑智能相册…",
"Edit Spot": "编辑污点",
"Edit in External Editor": "在外部编辑器中编辑",
"Edit settings copied": "已复制编辑设置",
"Edited": "已编辑",
"Edited or not": "不限是否编辑",
"Editing": "编辑",
"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": "擦除",
"Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段",
"Existing previews": "现有预览",
"Exit": "退出",
"Expand All Stacks": "展开所有堆叠",
"Expand Stack": "展开堆叠",
"Expand/Collapse Stack": "展开/折叠堆叠",
"Export": "导出",
"Export Album with Preset": "使用预设导出相册",
@@ -372,6 +455,7 @@
"Export Presets": "导出预设",
"Export Presets…": "导出预设…",
"Export User Presets…": "导出用户预设…",
"Export stopped unexpectedly": "导出意外停止",
"Export with Preset": "使用预设导出",
"Export with Previous": "使用上次设置导出",
"Exporting": "正在导出",
@@ -380,6 +464,9 @@
"Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)",
"Extended Description": "扩展描述",
"External Editor": "外部编辑器",
"Face Boxes": "人脸框",
"Face boxes": "人脸框",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroom 及其他应用写入 XMP 的人脸名称会显示在这里。",
"Faded Matte": "褪色哑光",
"Faded Slide": "褪色正片",
"Faded Urban": "褪色都市",
@@ -389,13 +476,128 @@
"Feather": "羽化",
"File": "文件",
"File & date": "文件 & 日期",
"File Format": "文件格式",
"File Name": "文件名",
"File Path": "文件路径",
"File Size": "文件大小",
"File Type": "文件类型",
"Filename": "文件名",
"Fill": "填满",
"First": "最早",
"First commit": "首次提交",
"Five stars": "五星",
"Flagged as Pick": "已标记为留用",
"Flagged as Reject": "已标记为排除",
"Focal Length": "焦距",
"Focus (assisted culling)": "对焦(辅助筛选)",
"Frame time": "帧耗时",
"Frame update": "帧更新",
"GPU rendering": "GPU 渲染",
"Generated demo photo": "生成的演示照片",
"Grab bag": "名单",
"Group name": "分组名称",
"HDR Merge Preview": "HDR 合并预览",
"HDR Panorama Merge Preview": "HDR 全景合并预览",
"HDR Panoramas": "HDR 全景",
"HDR panorama": "HDR 全景",
"HDR panoramas": "HDR 全景",
"Has Edits": "有编辑",
"Has GPS": "有 GPS",
"Home": "主文件夹",
"Hours": "小时",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import as new": "作为新照片导入",
"Imported": "已导入",
"Info Overlay Off": "关闭信息叠加",
"Info Overlay: Exposure": "信息叠加:曝光",
"Info Overlay: File": "信息叠加:文件",
"Insert at the cursor": "在光标处插入",
"Invert Component": "反相组件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
"Label": "标签",
"Label Set": "标签集",
"Last GPU fallback": "最近一次 GPU 回退",
"Last commit": "最近一次提交",
"Last loupe render": "最近一次放大视图渲染",
"Lens Blur": "镜头模糊",
"Library saved": "图库已保存",
"Licence:": "许可证:",
"Line delta": "行数差",
"Linear": "线性",
"Lines +/−": "行数 +/−",
"Lines added": "新增行数",
"Lines deleted": "删除行数",
"Lock": "锁定",
"Lock Crop Aspect": "锁定裁剪长宽比",
"Locked": "已锁定",
"Mask Adjustment": "蒙版调整",
"Memory budget": "内存预算",
"Merged": "已合并",
"Merged PRs": "已合并的 PR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "适用的是 Meta 的条款,而非 LightCraft 的条款。下载即表示接受这些条款。",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "缺少的元数据({camera}、{title}…)会留空;如果生成的名称为空,则保留原始名称。",
"Model": "型号",
"Models": "模型",
"Move Down": "下移",
"Move Preset to New Group": "将预设移动到新分组",
"Move Up": "上移",
"Move in Stack": "在堆叠中移动",
"Name (A–Z)": "名称(A–Z)",
"Name of the folder the original is in": "原始文件所在文件夹的名称",
"Named People": "已命名的人物",
"Nested group": "嵌套组",
"New time": "新时间",
"No contributor data was built into this copy.": "此版本未内置贡献者数据。",
"No model credits were built into this copy.": "此版本未内置模型致谢信息。",
"No named people in these photos": "这些照片中没有已命名的人物",
"No named people yet": "还没有已命名的人物",
"Not Now": "以后再说",
"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.": "对象蒙版和描述蒙版使用 Meta 的分割模型 SAM 3。它不属于 LightCraft,其他所有功能无需它也能使用。",
"October 2026": "2026年10月",
"Only": "仅限",
"Original extension without the dot": "不含点的原始扩展名",
"Original file name without its extension": "不含扩展名的原始文件名",
"Panorama": "全景",
"Panorama Merge Preview": "全景合并预览",
"Panoramas": "全景",
"Paste Settings": "粘贴设置",
"Paste from Previous": "粘贴上一张照片的设置",
"People": "人物",
"Person": "人物",
"Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)",
"Pick Flag": "留用旗标",
"Pictures": "图片",
"Prepare AI Masks": "准备 AI 蒙版",
"Presets and profiles": "预设和配置文件",
"Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG",
"Proof copy created": "已创建校样副本",
"Quit Anyway": "仍然退出",
"Quit with unsaved changes?": "有未保存的更改,确定退出吗?",
"RAW decoding": "RAW 解码",
"Radial": "径向",
"Random": "随机",
"Rating cleared": "已清除星级",
"Raw": "RAW",
"Read the SAM License": "阅读 SAM 许可证",
"Real name": "真实姓名",
"Recent": "最近使用",
"Recent Keywords": "最近使用的关键字",
"Refine": "细化",
"Refine AI Mask Detail": "细化 AI 蒙版细节",
"Refine Edges": "细化边缘",
"Refine Saturation": "细化饱和度",
"Refining the mask's detail…": "正在细化蒙版细节…",
"Reliability": "可靠性",
"Relink Photo": "重新链接照片",
"Reload Changed Files": "重新载入已更改的文件",
"Remove Face Box": "移除人脸框",
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
"Remove from Target Album": "从目标相册中移除",
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
"Rename Component": "重命名组件",
"Reorder Masks": "重新排序蒙版",
"Reshuffle": "重新随机排序",
"File name": "文件名",
"Fill Edges": "填充边缘",
@@ -937,6 +1139,7 @@
"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)",
"Resize Face Box": "调整人脸框大小",
"Resize to": "调整大小为",
"Resolution (ppi)": "分辨率 (ppi)",
"Restore": "恢复",
@@ -945,17 +1148,22 @@
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
"Restore them": "恢复",
"Retouch": "润饰",
"Reverse the order": "倒序排列",
"Revert": "恢复",
"Review": "审阅",
"Review Import": "查看导入内容",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)",
"Right-click a preset to delete it": "右键单击预设即可将其删除",
"Rights Usage Terms": "使用条款",
"Rotate": "旋转",
"Rotate Crop": "旋转裁剪",
"Rotate Crop Aspect": "旋转裁切长宽比",
"Rotate Left": "向左旋转",
"Rotate Right": "向右旋转",
"Roughness": "粗糙度",
"Roundness": "圆度",
"Rules": "规则",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3 下载已停止:下次将从中断处继续。",
"Same as raw default": "与 RAW 默认设置相同",
"Sample Mask Color": "取样蒙版颜色",
"Sample a colour on the photo": "在照片上取样颜色",
@@ -976,14 +1184,17 @@
"Save Point Curve Preset": "保存点曲线预设",
"Save Point Curve…": "保存点曲线…",
"Save as Preset": "保存为预设",
"Save as Set…": "存储为标签集…",
"Save as Smart Album…": "保存为智能相册…",
"Save as set": "保存为集",
"Save as…": "另存为…",
"Save preset": "保存预设",
"Scale": "缩放",
"Scan failed": "扫描失败",
"Scanning": "正在扫描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。",
"Screen": "屏幕",
"Search": "搜索",
"Search Photos": "搜索照片",
"Search…": "搜索…",
"Seasons": "四季",
@@ -1000,10 +1211,15 @@
"Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)",
"Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)",
"Selected": "已选择",
"Selecting…": "正在选择…",
"Selenium Tone": "硒调色",
"Send Feedback…": "发送反馈…",
"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": "设置裁切",
@@ -1014,15 +1230,20 @@
"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": "根据照片颜色设置混合",
"Settings": "设置",
"Settings file problem": "设置文件有问题",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片的初始设置。更改这些设置不会影响图库中已有的照片。",
"Settings pasted": "已粘贴设置",
"Settings to include": "要包含的设置",
"Settings…": "设置…",
"Shade": "阴影",
@@ -1036,7 +1257,10 @@
"Sharpen": "锐化",
"Sharpening": "锐化",
"Sharpness": "锐度",
"Shift": "偏移量",
"Shift by": "偏移",
"Short Edge": "短边",
"Show": "显示",
"Show All": "显示全部",
"Show Clipping": "显示溢出警告",
"Show Deghost Overlay": "显示去重影叠加",
@@ -1073,11 +1297,16 @@
"Skip": "跳过",
"Sky": "天空",
"Slideshow": "幻灯片播放",
"Slideshow paused": "幻灯片播放已暂停",
"Slideshow resumed": "幻灯片播放已继续",
"Slideshow · Space pauses · Esc ends": "幻灯片播放 · 空格键暂停 · Esc 结束",
"Smart Album": "智能相册",
"Smart Album Rule Fields": "智能相册规则字段",
"Smart Preview Status": "智能预览状态",
"Smart Previews Location": "智能预览位置",
"Smart preview available": "有智能预览",
"Smart previews": "智能预览",
"Smart previews folder changed": "智能预览文件夹已更改",
"Smoothness": "平滑度",
"Soft B&W": "柔和黑白",
"Soft Pastel": "柔和粉彩",
@@ -1098,8 +1327,10 @@
"Stack photos taken within this time of each other:": "将拍摄时间在此间隔内的照片堆叠在一起:",
"Stacks": "堆叠",
"Standard": "标准",
"Star rating (0–5)": "星级(0–5)",
"Start at": "起始编号",
"Start number": "起始编号",
"Starting": "正在开始",
"Startup": "启动",
"State / Province": "省 / 州",
"Stop": "停止",
@@ -1114,6 +1345,7 @@
"Subfolder name": "子文件夹名称",
"Subject": "主题",
"Subtract": "减去",
"Subtract from this mask": "从此蒙版中减去",
"Suggestions": "建议",
"Summer Haze": "夏日薄雾",
"Sunday": "星期日",
@@ -1121,13 +1353,16 @@
"Survey (N)": "筛选视图(N)",
"Swap": "交换",
"Swap Aspect (X)": "交换长宽比(X)",
"Swap Before and After": "交换修改前和修改后",
"Swap Before and After Settings": "交换修改前和修改后的设置",
"Swap Compare Photos": "交换对比照片",
"Switch light / dark theme": "切换浅色/深色主题",
"Sync Settings": "同步设置",
"System": "系统",
"System Info": "系统信息",
"System Info…": "系统信息…",
"System default": "系统默认",
"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": "创建快照",
@@ -1143,19 +1378,27 @@
"Text": "文字",
"Text direction": "文字方向",
"Texture": "纹理",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安装:对象蒙版和描述蒙版可以使用了。",
"The download didn't work:": "下载失败:",
"The number at the end of the original name (IMG_0042 → 0042)": "原始名称末尾的数字(IMG_0042 → 0042)",
"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": "此照片具有这些设置",
"Theme": "主题",
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下载地址:请自行将文件放入上方的文件夹(参见 docs/ai-masks.md)。",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。",
"This photo can't be opened": "无法打开此照片",
"Threshold": "阈值",
"Thumbnail cache": "缩略图缓存",
"Thursday": "星期四",
"Time zone": "时区",
"Tint": "色调",
"Title": "标题",
"Title metadata": "元数据中的标题",
"Toggle Keyword from Set": "切换关键字集中的关键字",
"Toggle Mask": "切换蒙版",
"Toggle Section": "切换板块",
"Toggle Zoom": "切换缩放",
"Tone Curve": "色调曲线",
@@ -1165,6 +1408,7 @@
"Treatment": "处理方式",
"Triangle": "三角形",
"Try Again": "重试",
"Try Saving Again": "再次尝试保存",
"Tuesday": "星期二",
"Tungsten": "钨丝灯",
"Turn Off": "关闭",
@@ -1173,10 +1417,12 @@
"Uncheck All": "全部取消选中",
"Undo": "撤销",
"Unedited": "未编辑",
"Unedited raws": "未编辑的 RAW",
"Unflag": "取消旗标",
"Unflagged": "无旗标",
"Ungroup Stack": "取消堆叠",
"Unknown Date": "未知日期",
"Unknown tags stay as typed — check the preview for a {typo}.": "未知标签会按原样保留 — 请在预览中检查是否有{typo}。",
"Unrated": "未评级",
"Unreadable": "无法读取",
"Update": "更新",
@@ -1186,6 +1432,7 @@
"Update Version": "更新版本",
"Update Version with Current Settings": "使用当前设置更新版本",
"Update with Current Settings": "使用当前设置更新",
"Updated the edits saved in the external editor": "已更新在外部编辑器中保存的编辑",
"Upright": "垂直校正",
"Urban": "都市",
"Use Keyword Set": "使用关键字集",
@@ -1193,6 +1440,7 @@
"Use camera-specific defaults": "使用相机专属默认值",
"Use the GPU for rendering": "使用 GPU 渲染",
"User Presets": "用户预设",
"Username": "用户名",
"Value": "值",
"Variance": "变化幅度",
"Version": "版本",
@@ -1204,6 +1452,7 @@
"Vibrance": "鲜艳度",
"Videos": "视频",
"View": "视图",
"Viewing and sharing": "查看与分享",
"Vignette": "暗角",
"Vignetting": "暗角",
"Vintage": "复古",
@@ -1243,6 +1492,8 @@
"Yellow Luminance": "黄色明度",
"Yellow Saturation": "黄色饱和度",
"Yes": "是",
"You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。",
"Your Name": "你的名字",
"Your library couldn't be opened": "无法打开图库",
"Zoom": "缩放",
"Zoom 100%": "缩放 100%",
@@ -1250,13 +1501,82 @@
"Zoom Out": "缩小",
"Zoom to Fit": "适合窗口",
"Zoom to page level": "缩放至页面级别",
"a literal %": "字面上的 %",
"a removed photo": "一张已移除的照片",
"all": "全部",
"and": "和",
"any": "任一",
"are empty": "为空",
"aren't empty": "不为空",
"blue": "蓝色",
"comma, separated": "逗号分隔",
"contains": "包含",
"copyrighted": "受版权保护",
"day 01–31": "日 01–31",
"days": "天",
"discarding smart previews": "正在丢弃智能预览",
"doesn't contain": "不包含",
"e.g. sky · the red car · car, road": "例如(英文):sky · the red car · car, road",
"ends with": "结尾为",
"false": "否",
"green": "绿色",
"hour 00–23": "时 00–23",
"hours": "小时",
"image": "图像",
"is": "是",
"is <": "小于",
"is >": "大于",
"is after": "晚于",
"is before": "早于",
"is between": "介于",
"is empty": "为空",
"is in the last": "在最近",
"is unknown": "未知",
"is ≤": "小于等于",
"is ≥": "大于等于",
"isn't": "不是",
"isn't empty": "不为空",
"isn't in the last": "不在最近",
"keep names — or e.g. {date}_{seq:3}": "保留名称 — 或如 {date}_{seq:3}",
"latitude, longitude": "纬度, 经度",
"memory only": "仅内存",
"minute": "分",
"month 01–12": "月 01–12",
"months": "个月",
"none": "无",
"none (CPU rendering)": "无(CPU 渲染)",
"none of": "均不符合",
"not available here": "此处不可用",
"of the following rules:": "以下规则:",
"or": "或",
"pick": "留用",
"previews": "预览",
"publicDomain": "公共领域",
"purple": "紫色",
"raw": "RAW",
"red": "红色",
"reject": "排除",
"saved in": "保存在",
"second": "秒",
"smart previews": "智能预览",
"starts with": "开头为",
"the photos in view": "视图中的照片",
"the selected photos": "所选照片",
"this photo": "这张照片",
"true": "是",
"unknown": "未知",
"video": "视频",
"weeks": "周",
"year, 2 digits": "年,2 位",
"year, 4 digits": "年,4 位",
"years": "年",
"yellow": "黄色",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} 是拍摄时间;没有拍摄时间的照片使用其导入时间。",
"© 2026 Your Name": "© 2026 你的名字",
"© Your Name": "© 你的名字",
"− harder border · + softer border": "− 边缘更硬 · + 边缘更柔和",
"≤ at most": "≤ 最多",
"≥ at least": "≥ 至少",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"In Recently Deleted": "在“最近删除”中",
+55 -1
View File
@@ -122,5 +122,59 @@
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入",
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}"
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}",
" ({unsure} with several look-alike files: use Locate)": "(其中 {unsure} 張有多個相似檔案:請使用「尋找」)",
" · {:.0}% clipped": " · {:.0}% 剪裁",
" · {failed} couldn't be rendered": " · {failed} 張無法算繪",
" · {} original{} left at the source": " · {} 個原始檔留在來源位置{:.0}",
" — not carried over: {}{}": " — 未沿用:{}{}",
"+{} more": "+{} 個",
"; {kept} already had a location": ";{kept} 張已有位置資訊",
"; {outside} outside its time range": ";{outside} 張不在其時間範圍內",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}:{} 個 PR,{} 次提交,+{} / −{} 行(Δ {}),+{} / −{} 個二進位資產,{} – {}",
"Added {what} to {name}": "已將{what}加入 {name}",
"Auto Import: added {} photo{}": "自動匯入:已新增 {} 張照片{:.0}",
"Building previews for {} photos…": "正在為 {} 張照片建立預覽…",
"Couldn't open the editor: {e}": "無法開啟編輯器:{e}",
"Creates {} stacks from {} of {scope}": "將從{scope}中的 {1} 張照片建立 {0} 個堆疊",
"Done ({what}): {done} photo{plural}": "完成({what}):{done} 張照片{plural:.0}",
"Focus {:.0}": "焦點 {:.0}",
"Folder “{}” (and its subfolders)": "資料夾「{}」(含子資料夾)",
"Found {n} missing photo{}; {left} still missing{unsure}": "已找到 {n} 張遺失的照片{:.0};仍有 {left} 張遺失{unsure}",
"Import failed: {e}": "匯入失敗:{e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "已匯入 {profiles} 個描述檔{:.0}(描述檔瀏覽器 ▸ 各自的群組)",
"Insert at the cursor (also written {})": "在游標處插入(也可寫成 {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0。字型:{}(OFL)。圖示:原創。",
"Painting “{k}”: click photos to add or remove it · Esc stops": "正在套用「{k}」:點擊照片即可加入或移除 · 按 Esc 停止",
"Previews ready for {done} photo{plural}": "已為 {done} 張照片建立預覽{plural:.0}",
"Removed {what} from {name}": "已從 {name} 移除{what}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "在 {n} 張照片上重新命名「{from}」{:.0}(其下層關鍵字一併處理)。以 | 區隔層級,例如 Travel|Italy。改為現有名稱時,兩者會合併。",
"Replace {} with:": "將 {} 取代為:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "為{scope}評估焦點與曝光,並找出相隔數秒內拍攝的相似照片(連拍)。",
"Tagged {n} photo{} from the tracklog": "已依軌跡記錄標記 {n} 張照片的位置{:.0}",
"The SAM 3 download failed: {e}": "SAM 3 下載失敗:{e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "未知的標籤{s:.0} {} 會保持原樣{:.0}(請參閱「標籤」)",
"Virtual copy “{name}” of {of}": "{of} 的虛擬副本「{name}」",
"Working on {what} for {} photos…": "正在為 {} 張照片處理{what}…",
"{label} {index} of {n}": "{label} {index}/{n}",
"{name} opened for editing; it is stacked with the original": "已開啟 {name} 進行編輯;它已與原始照片堆疊在一起",
"{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} — LightCraft will retry": "{n} 項變更{:.0}已儲存在記憶體中,但未寫入磁碟:{e} — LightCraft 會重試",
"{pid} (missing)": "{pid}(遺失)",
"{{date:…}} directives: {}": "{{date:…}} 格式說明:{}",
"{} contributors · {} commits": "{} 位貢獻者 · {} 次提交",
"{} failed": "{}失敗",
"{} item{}": "{} 個項目{:.0}",
"{} photos · exposures {} EV": "{} 張照片 · 曝光 {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} 張照片 · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} 個樣本{:.0}",
"{} sec": "{} 秒",
"{} — show their photos": "{} — 顯示其照片",
"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 預覽,其中已包含相機的相片風格(例如單色)與白平衡。",
"Filtered: {label}": "已篩選:{label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} 張相似照片{:.0} · 選擇「檢視 ▸ 清除篩選條件」可查看全部",
"Moved; {} photo(s) relinked": "已移動;已重新連結 {} 張照片",
"{} label (click more labels to show any of them)": "{}標籤(點擊更多標籤可顯示符合其中任一標籤的照片)",
", {} star{}": ",{} 顆星{:.0}",
", {} label": ",{}標籤"
}
+320
View File
@@ -1,14 +1,31 @@
{
" (library default)": " (照片庫預設值)",
" (inverted)": " (已反轉)",
" · best of its burst": " · 連拍中的最佳照片",
" · in a burst": " · 屬於一組連拍",
"% of all commits": "佔所有提交的百分比",
"+ Group": "+ 群組",
"+ Rule": "+ 規則",
", picked": ",精選",
", rejected": ",排除",
"0: nothing is rejected.": "0:不排除任何照片。",
"1 day": "1 天",
"2026-04 or 2026-04-12": "2026-04 或 2026-04-12",
"= exactly": "= 等於",
"A blank template keeps the original names.": "範本留空時保留原始名稱。",
"A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」",
"A one-time download of about": "一次性下載,大小約",
"AI Mask Model Status": "AI 遮色片模型狀態",
"About": "關於",
"About LightCraft": "關於 LightCraft",
"Active": "目前照片",
"Activity": "處理狀態",
"Actual size": "實際大小",
"Add": "新增",
"Add Catchlight": "新增眼神光",
"Add Folder to Local": "將資料夾加入本機",
"Add Mask": "新增遮色片",
"Add Object Click": "新增物件點擊",
"Add Point Color Sample": "新增點色彩樣本",
"Add Red Eye Correction": "新增紅眼校正",
"Add Remove Spot": "新增移除斑點",
@@ -21,9 +38,11 @@
"Add to": "加入至",
"Add to Album": "加入相簿",
"Add to Favorites": "加入喜好項目",
"Add to Local": "加入本機",
"Add to My Photos": "加入我的照片",
"Add to Selected Photos": "加入選取的照片",
"Add to Target Album": "加入目標相簿",
"Add to this mask": "加入此遮色片",
"Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
"Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
"Adding": "正在新增",
@@ -44,6 +63,11 @@
"Alt Text": "替代文字",
"Always": "一律",
"Amount": "強度",
"Analyzing the photo for AI masks…": "正在分析照片以建立 AI 遮色片…",
"Any Searchable Text": "任何可搜尋文字",
"Any camera": "任何相機",
"Any keyword": "任何關鍵字",
"Any lens": "任何鏡頭",
"Aperture": "光圈",
"Application": "應用程式",
"Apply": "套用",
@@ -80,9 +104,11 @@
"Auto Import Now": "立即自動匯入",
"Auto Import Settings": "自動匯入設定",
"Auto Mask": "自動遮色片",
"Auto Select": "自動選取",
"Auto Settings": "自動設定",
"Auto Straighten": "自動拉直",
"Auto Sync": "自動同步",
"Auto settings applied": "已套用自動設定",
"Auto-Stack by Capture Time": "依拍攝時間自動堆疊",
"Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…",
"Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置",
@@ -110,6 +136,9 @@
"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": "黑色色階",
@@ -140,19 +169,32 @@
"By Date": "依日期",
"By day (YYYY/YYYY-MM-DD)": "依日期(YYYY/YYYY-MM-DD)",
"By month (YYYY/YYYY-MM)": "依月份(YYYY/YYYY-MM)",
"CPU threads": "CPU 執行緒",
"Calibration": "校正",
"Camera": "相機",
"Camera make and model": "相機品牌與型號",
"Cameras and Cards": "相機與記憶卡",
"Cancel": "取消",
"Cancel AI Mask Model Download": "取消下載 AI 遮色片模型",
"Cancel Download": "取消下載",
"Cancel Interaction": "取消互動",
"Cancel Preview Build": "取消建立預覽",
"Candidate": "候選照片",
"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…。",
"Check All": "全部勾選",
"Check Output Path": "檢驗輸出路徑",
"Checking names…": "正在檢查名稱…",
"Choose Another Library…": "選擇其他照片庫…",
"Choose Edit Settings to Copy": "選擇要複製的編輯設定",
"Choose Edit Settings to Copy…": "選擇要複製的編輯設定…",
@@ -177,8 +219,11 @@
"Click Zoom Ratio": "點擊縮放比例",
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。",
"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": "點擊以還原 · 按兩下以重新命名",
"Clone": "仿製",
"Close": "關閉",
"Close Panel": "關閉面板",
@@ -187,7 +232,9 @@
"Close file": "關閉檔案",
"Cloudy": "陰天",
"Collapse All Stacks": "收合所有堆疊",
"Collapse Stack": "收合堆疊",
"Color": "色彩",
"Color Label": "色彩標籤",
"Color label cleared": "已清除色彩標籤",
"Color Grading": "色彩分級",
"Color Label Names": "色彩標籤名稱",
@@ -198,9 +245,12 @@
"Color Range": "色彩範圍",
"Color space": "色彩空間",
"Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)",
"Colors": "色彩",
"Combine files…": "合併檔案…",
"Command Journal": "指令記錄",
"Comments panel": "註解面板",
"Commits": "提交",
"Company": "公司",
"Compare": "比較",
"Compare (Shift+C)": "比較(Shift+C)",
"Compare Before and After": "比較編輯前與編輯後",
@@ -211,13 +261,16 @@
"Confirm before deleting": "刪除前確認",
"Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
"Constrain Crop": "限制裁切範圍",
"Continue": "繼續",
"Continue Without Saving": "繼續使用但不儲存",
"Contrast": "對比",
"Contributors": "貢獻者",
"Convert to 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": "將歷史步驟設定複製至編輯前",
@@ -252,16 +305,19 @@
"Create Version from Step": "依此步驟建立版本",
"Create Virtual Copy": "建立虛擬副本",
"Creator": "創作者",
"Creator metadata": "中繼資料中的創作者",
"Crisp Landscape": "清晰風景",
"Crisp Vista": "清晰遠景",
"Crop": "裁切",
"Crop & Rotate": "裁切與旋轉",
"Crop & Rotate (C)": "裁切與旋轉(C)",
"Crop Aspect": "裁切長寬比",
"Crop, Rotate, Geometry": "裁切、旋轉、幾何",
"Cross Process": "交叉沖洗",
"Culling": "挑片",
"Curve": "曲線",
"Custom": "自訂",
"Custom template…": "自訂範本…",
"Custom…": "自訂…",
"Cut": "剪下",
"Cut pages": "剪下頁面",
@@ -274,6 +330,7 @@
"Dark & Moody": "暗調氛圍",
"Darken": "變暗",
"Darks": "暗部",
"Date": "日期",
"Date Groups": "日期群組",
"Day": "日",
"Daylight": "日光",
@@ -288,6 +345,7 @@
"Delete Album": "刪除相簿",
"Delete All Masks": "刪除所有遮色片",
"Delete Color Label Set": "刪除色彩標籤集",
"Delete Component": "刪除元件",
"Delete Export Preset": "刪除匯出預設集",
"Delete Filter Preset": "刪除篩選預設集",
"Delete Imported Profile": "刪除匯入的描述檔",
@@ -298,9 +356,11 @@
"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": "刪除版本",
@@ -311,27 +371,41 @@
"Density": "濃度",
"Depth Range": "深度範圍",
"Descending": "遞減",
"Describe": "描述",
"Describe what to keep": "描述要保留的內容",
"Describe what to leave out": "描述要排除的內容",
"Describe what to select": "描述要選取的內容",
"Describe…": "描述…",
"Deselect": "取消選取",
"Deselect All": "取消全選",
"Desert Heat": "沙漠熱浪",
"Desert Warmth": "沙漠暖色",
"Desktop": "桌面",
"Detail": "詳細檢視",
"Detail (D)": "詳細檢視(D)",
"Details": "詳細資訊",
"Diagonal": "對角線",
"Discard Preview Cache": "捨棄預覽快取",
"Display P3": "Display P3",
"Display name": "顯示名稱",
"Display theme": "顯示主題",
"Distortion": "變形",
"Document properties…": "文件內容…",
"Don't enlarge": "不放大",
"Done": "完成",
"Download": "下載",
"Download AI Mask Model": "下載 AI 遮色片模型",
"Download the SAM 3 Model?": "要下載 SAM 3 模型嗎?",
"Downloads": "下載項目",
"Drag along the horizon; double-click for Auto": "沿水平線拖曳;按兩下可自動校正",
"Drag over an eye on the photo; the pupil inside is found automatically.": "在照片的眼睛上拖曳,會自動尋找範圍內的瞳孔。",
"Draw Guides": "繪製參考線",
"Duplicate": "複製",
"Duplicate Component": "複製元件",
"Duplicate Mask": "複製遮色片",
"Duplicate and Invert Mask": "複製並反轉遮色片",
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每個 / 代表一層資料夾;標籤會依照片填入(缺少中繼資料的層級使用「unknown」)。",
"Edge": "邊緣",
"Edit": "編輯",
"Edit (E)": "編輯(E)",
"Edit Capture Time": "編輯拍攝時間",
@@ -339,27 +413,36 @@
"Edit Color Label Names": "編輯色彩標籤名稱",
"Edit Color Label Names…": "編輯色彩標籤名稱…",
"Edit Copy for External Editor": "編輯外部編輯器副本",
"Edit Date": "編輯日期",
"Edit Info": "編輯資訊",
"Edit Label Names": "編輯標籤名稱",
"Edit Label Names…": "編輯標籤名稱…",
"Edit Mask": "編輯遮色片",
"Edit Mask Component": "編輯遮色片元件",
"Edit Sections": "編輯區段",
"Edit Smart Album": "編輯智慧型相簿",
"Edit Smart Album…": "編輯智慧型相簿…",
"Edit Spot": "編輯斑點",
"Edit in External Editor": "在外部編輯器中編輯",
"Edit settings copied": "已複製編輯設定",
"Edited": "已編輯",
"Edited or not": "不限是否編輯",
"Editing": "編輯",
"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": "擦除",
"Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位",
"Existing previews": "現有預覽",
"Exit": "結束",
"Expand All Stacks": "展開所有堆疊",
"Expand Stack": "展開堆疊",
"Expand/Collapse Stack": "展開/收合堆疊",
"Export": "匯出",
"Export Album with Preset": "使用預設集匯出相簿",
@@ -372,6 +455,7 @@
"Export Presets": "匯出預設集",
"Export Presets…": "匯出預設集…",
"Export User Presets…": "匯出使用者預設集…",
"Export stopped unexpectedly": "匯出意外停止",
"Export with Preset": "使用預設集匯出",
"Export with Previous": "使用上次設定匯出",
"Exporting": "正在匯出",
@@ -380,6 +464,9 @@
"Exposure (shutter · aperture · ISO)": "曝光(快門 · 光圈 · ISO)",
"Extended Description": "延伸說明",
"External Editor": "外部編輯器",
"Face Boxes": "臉部框",
"Face boxes": "臉部框",
"Face names written to XMP by Lightroom and other apps show up here.": "Lightroom 及其他應用程式寫入 XMP 的臉部名稱會顯示在這裡。",
"Faded Matte": "褪色霧面",
"Faded Slide": "褪色正片",
"Faded Urban": "褪色城市",
@@ -389,13 +476,128 @@
"Feather": "羽化",
"File": "檔案",
"File & date": "檔案與日期",
"File Format": "檔案格式",
"File Name": "檔案名稱",
"File Path": "檔案路徑",
"File Size": "檔案大小",
"File Type": "檔案類型",
"Filename": "檔名",
"Fill": "填滿",
"First": "最早",
"First commit": "首次提交",
"Five stars": "五顆星",
"Flagged as Pick": "已標記為精選",
"Flagged as Reject": "已標記為排除",
"Focal Length": "焦距",
"Focus (assisted culling)": "焦點(輔助挑選)",
"Frame time": "影格時間",
"Frame update": "影格更新",
"GPU rendering": "GPU 算繪",
"Generated demo photo": "產生的示範照片",
"Grab bag": "名單",
"Group name": "群組名稱",
"HDR Merge Preview": "HDR 合併預覽",
"HDR Panorama Merge Preview": "HDR 全景合併預覽",
"HDR Panoramas": "HDR 全景",
"HDR panorama": "HDR 全景",
"HDR panoramas": "HDR 全景",
"Has Edits": "有編輯",
"Has GPS": "有 GPS",
"Home": "個人資料夾",
"Hours": "小時",
"ISO Speed": "ISO 感光度",
"ISO speed": "ISO 感光度",
"Import as new": "以新照片匯入",
"Imported": "已匯入",
"Info Overlay Off": "關閉資訊疊加",
"Info Overlay: Exposure": "資訊疊加:曝光",
"Info Overlay: File": "資訊疊加:檔案",
"Insert at the cursor": "在游標處插入",
"Invert Component": "反轉元件",
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
"Label": "標籤",
"Label Set": "標籤集",
"Last GPU fallback": "最近一次 GPU 備援",
"Last commit": "最近一次提交",
"Last loupe render": "最近一次放大檢視算繪",
"Lens Blur": "鏡頭模糊",
"Library saved": "照片庫已儲存",
"Licence:": "授權:",
"Line delta": "行數差",
"Linear": "線性",
"Lines +/−": "行數 +/−",
"Lines added": "新增行數",
"Lines deleted": "刪除行數",
"Lock": "鎖定",
"Lock Crop Aspect": "鎖定裁切長寬比",
"Locked": "已鎖定",
"Mask Adjustment": "遮色片調整",
"Memory budget": "記憶體預算",
"Merged": "已合併",
"Merged PRs": "已合併的 PR",
"Meta's terms, not LightCraft's. Downloading it means accepting them.": "適用的是 Meta 的條款,而非 LightCraft 的條款。下載即表示接受這些條款。",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "缺少的中繼資料({camera}、{title}…)會留空;產生的名稱若為空白,則保留原始名稱。",
"Model": "型號",
"Models": "模型",
"Move Down": "下移",
"Move Preset to New Group": "將預設集移至新群組",
"Move Up": "上移",
"Move in Stack": "在堆疊中移動",
"Name (A–Z)": "名稱(A–Z)",
"Name of the folder the original is in": "原始檔所在資料夾的名稱",
"Named People": "已命名的人物",
"Nested group": "巢狀群組",
"New time": "新時間",
"No contributor data was built into this copy.": "此版本未內建貢獻者資料。",
"No model credits were built into this copy.": "此版本未內建模型致謝資訊。",
"No named people in these photos": "這些照片中沒有已命名的人物",
"No named people yet": "尚無已命名的人物",
"Not Now": "以後再說",
"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.": "物件遮色片與描述遮色片使用 Meta 的分割模型 SAM 3。它不屬於 LightCraft,其他所有功能不需要它也能使用。",
"October 2026": "2026年10月",
"Only": "僅限",
"Original extension without the dot": "不含點的原始副檔名",
"Original file name without its extension": "不含副檔名的原始檔名",
"Panorama": "全景",
"Panorama Merge Preview": "全景合併預覽",
"Panoramas": "全景",
"Paste Settings": "貼上設定",
"Paste from Previous": "貼上前一張照片的設定",
"People": "人物",
"Person": "人物",
"Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)",
"Pick Flag": "精選旗標",
"Pictures": "圖片",
"Prepare AI Masks": "準備 AI 遮色片",
"Presets and profiles": "預設集與描述檔",
"Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG",
"Proof copy created": "已建立校樣副本",
"Quit Anyway": "仍要結束",
"Quit with unsaved changes?": "有未儲存的變更,確定要結束嗎?",
"RAW decoding": "RAW 解碼",
"Radial": "放射狀",
"Random": "隨機",
"Rating cleared": "已清除評分",
"Raw": "RAW",
"Read the SAM License": "閱讀 SAM 授權",
"Real name": "真實姓名",
"Recent": "最近使用",
"Recent Keywords": "最近使用的關鍵字",
"Refine": "調整",
"Refine AI Mask Detail": "調整 AI 遮色片細節",
"Refine Edges": "調整邊緣",
"Refine Saturation": "調整飽和度",
"Refining the mask's detail…": "正在調整遮色片細節…",
"Reliability": "可靠度",
"Relink Photo": "重新連結照片",
"Reload Changed Files": "重新載入已變更的檔案",
"Remove Face Box": "移除臉部框",
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
"Remove from Target Album": "從目標相簿移除",
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
"Rename Component": "重新命名元件",
"Reorder Masks": "重新排序遮色片",
"Reshuffle": "重新隨機排序",
"File name": "檔案名稱",
"Fill Edges": "填補邊緣",
@@ -937,6 +1139,7 @@
"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)",
"Resize Face Box": "調整臉部框大小",
"Resize to": "調整尺寸為",
"Resolution (ppi)": "解析度(ppi)",
"Restore": "還原",
@@ -945,17 +1148,22 @@
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
"Restore them": "還原",
"Retouch": "修飾",
"Reverse the order": "反轉順序",
"Revert": "回復至上次儲存",
"Review": "審閱",
"Review Import": "檢視匯入",
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)",
"Right-click a preset to delete it": "在預設集上按右鍵即可刪除",
"Rights Usage Terms": "使用權利條款",
"Rotate": "旋轉",
"Rotate Crop": "旋轉裁切",
"Rotate Crop Aspect": "旋轉裁切長寬比",
"Rotate Left": "向左旋轉",
"Rotate Right": "向右旋轉",
"Roughness": "粗糙度",
"Roundness": "圓度",
"Rules": "規則",
"SAM 3 download stopped: it resumes where it left off next time.": "SAM 3 下載已停止:下次會從中斷處繼續。",
"Same as raw default": "與 RAW 預設值相同",
"Sample Mask Color": "取樣遮色片色彩",
"Sample a colour on the photo": "在照片上取樣色彩",
@@ -976,14 +1184,17 @@
"Save Point Curve Preset": "儲存點曲線預設集",
"Save Point Curve…": "儲存點曲線…",
"Save as Preset": "儲存為預設集",
"Save as Set…": "儲存為標籤集…",
"Save as Smart Album…": "儲存為智慧型相簿…",
"Save as set": "儲存為關鍵字集",
"Save as…": "另存新檔…",
"Save preset": "儲存預設集",
"Scale": "縮放",
"Scan failed": "掃描失敗",
"Scanning": "正在掃描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
"Screen": "螢幕",
"Search": "搜尋",
"Search Photos": "搜尋照片",
"Search…": "搜尋…",
"Seasons": "四季",
@@ -1000,10 +1211,15 @@
"Select photos, then choose View → Survey (N)": "選取照片後,選擇「檢視 → 總覽」(N)",
"Select two photos, then choose View → Compare (Shift+C)": "選取兩張照片後,選擇「檢視 → 比較」(Shift+C)",
"Selected": "已選取",
"Selecting…": "正在選取…",
"Selenium Tone": "硒調色",
"Send Feedback…": "傳送意見回饋…",
"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": "設定裁切",
@@ -1014,15 +1230,20 @@
"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": "依照片色彩設定混色",
"Settings": "設定",
"Settings file problem": "設定檔有問題",
"Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片使用的初始設定。變更不會影響照片庫中已有的照片。",
"Settings pasted": "已貼上設定",
"Settings to include": "要包含的設定",
"Settings…": "設定…",
"Shade": "陰影處",
@@ -1036,7 +1257,10 @@
"Sharpen": "銳利化",
"Sharpening": "銳利化",
"Sharpness": "銳利度",
"Shift": "位移量",
"Shift by": "位移",
"Short Edge": "短邊",
"Show": "顯示",
"Show All": "全部顯示",
"Show Clipping": "顯示溢色",
"Show Deghost Overlay": "顯示去除鬼影疊加",
@@ -1073,11 +1297,16 @@
"Skip": "略過",
"Sky": "天空",
"Slideshow": "幻燈片播放",
"Slideshow paused": "幻燈片播放已暫停",
"Slideshow resumed": "幻燈片播放已繼續",
"Slideshow · Space pauses · Esc ends": "幻燈片播放 · 空白鍵暫停 · Esc 結束",
"Smart Album": "智慧型相簿",
"Smart Album Rule Fields": "智慧型相簿規則欄位",
"Smart Preview Status": "智慧型預覽狀態",
"Smart Previews Location": "智慧型預覽位置",
"Smart preview available": "有智慧型預覽",
"Smart previews": "智慧型預覽",
"Smart previews folder changed": "智慧型預覽資料夾已變更",
"Smoothness": "平滑度",
"Soft B&W": "柔和黑白",
"Soft Pastel": "柔和粉彩",
@@ -1098,8 +1327,10 @@
"Stack photos taken within this time of each other:": "將拍攝時間間隔在此範圍內的照片堆疊:",
"Stacks": "堆疊",
"Standard": "標準",
"Star rating (0–5)": "星級評分(0–5)",
"Start at": "起始值",
"Start number": "起始編號",
"Starting": "正在開始",
"Startup": "啟動",
"State / Province": "州/省",
"Stop": "停止",
@@ -1114,6 +1345,7 @@
"Subfolder name": "子資料夾名稱",
"Subject": "主體",
"Subtract": "減去",
"Subtract from this mask": "從此遮色片減去",
"Suggestions": "建議",
"Summer Haze": "夏日薄霧",
"Sunday": "星期日",
@@ -1121,13 +1353,16 @@
"Survey (N)": "總覽(N)",
"Swap": "交換",
"Swap Aspect (X)": "交換長寬比(X)",
"Swap Before and After": "交換編輯前與編輯後",
"Swap Before and After Settings": "交換編輯前與編輯後設定",
"Swap Compare Photos": "交換比較照片",
"Switch light / dark theme": "切換淺色/深色主題",
"Sync Settings": "同步設定",
"System": "系統",
"System Info": "系統資訊",
"System Info…": "系統資訊…",
"System default": "系統預設值",
"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": "擷取快照",
@@ -1143,19 +1378,27 @@
"Text": "文字",
"Text direction": "文字方向",
"Texture": "紋理",
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安裝:物件遮色片與描述遮色片已可使用。",
"The download didn't work:": "下載失敗:",
"The number at the end of the original name (IMG_0042 → 0042)": "原始名稱結尾的數字(IMG_0042 → 0042)",
"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": "此照片具有這些設定",
"Theme": "主題",
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
"Thirds": "三分法",
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下載位置:請自行將檔案放入上方的資料夾(請參閱 docs/ai-masks.md)。",
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。",
"This photo can't be opened": "無法開啟此照片",
"Threshold": "臨界值",
"Thumbnail cache": "縮圖快取",
"Thursday": "星期四",
"Time zone": "時區",
"Tint": "色調",
"Title": "標題",
"Title metadata": "中繼資料中的標題",
"Toggle Keyword from Set": "切換關鍵字集中的關鍵字",
"Toggle Mask": "切換遮色片",
"Toggle Section": "切換區段",
"Toggle Zoom": "切換縮放",
"Tone Curve": "色調曲線",
@@ -1165,6 +1408,7 @@
"Treatment": "處理方式",
"Triangle": "三角形",
"Try Again": "再試一次",
"Try Saving Again": "再試一次儲存",
"Tuesday": "星期二",
"Tungsten": "鎢絲燈",
"Turn Off": "關閉",
@@ -1173,10 +1417,12 @@
"Uncheck All": "全部取消勾選",
"Undo": "復原",
"Unedited": "未編輯",
"Unedited raws": "未編輯的 RAW",
"Unflag": "移除旗標",
"Unflagged": "無旗標",
"Ungroup Stack": "取消堆疊",
"Unknown Date": "未知日期",
"Unknown tags stay as typed — check the preview for a {typo}.": "未知的標籤會保持原樣 — 請在預覽中檢查是否有{typo}。",
"Unrated": "未評分",
"Unreadable": "無法讀取",
"Update": "更新",
@@ -1186,6 +1432,7 @@
"Update Version": "更新版本",
"Update Version with Current Settings": "以目前設定更新版本",
"Update with Current Settings": "以目前設定更新",
"Updated the edits saved in the external editor": "已更新在外部編輯器中儲存的編輯內容",
"Upright": "透視校正",
"Urban": "城市",
"Use Keyword Set": "使用關鍵字集",
@@ -1193,6 +1440,7 @@
"Use camera-specific defaults": "使用個別相機的預設值",
"Use the GPU for rendering": "使用 GPU 算繪",
"User Presets": "使用者預設集",
"Username": "使用者名稱",
"Value": "數值",
"Variance": "變異數",
"Version": "版本",
@@ -1204,6 +1452,7 @@
"Vibrance": "自然飽和度",
"Videos": "影片",
"View": "檢視",
"Viewing and sharing": "檢視與分享",
"Vignette": "暗角",
"Vignetting": "暗角",
"Vintage": "復古",
@@ -1243,6 +1492,8 @@
"Yellow Luminance": "黃色明度",
"Yellow Saturation": "黃色飽和度",
"Yes": "是",
"You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。",
"Your Name": "你的名字",
"Your library couldn't be opened": "無法開啟你的照片庫",
"Zoom": "縮放",
"Zoom 100%": "縮放至 100%",
@@ -1250,13 +1501,82 @@
"Zoom Out": "縮小",
"Zoom to Fit": "縮放至符合視窗",
"Zoom to page level": "符合頁面大小",
"a literal %": "字面上的 %",
"a removed photo": "一張已移除的照片",
"all": "全部",
"and": "且",
"any": "任一",
"are empty": "為空",
"aren't empty": "不為空",
"blue": "藍色",
"comma, separated": "以逗號分隔",
"contains": "包含",
"copyrighted": "受著作權保護",
"day 01–31": "日 01–31",
"days": "天",
"discarding smart previews": "正在捨棄智慧型預覽",
"doesn't contain": "不包含",
"e.g. sky · the red car · car, road": "例如(英文):sky · the red car · car, road",
"ends with": "結尾為",
"false": "否",
"green": "綠色",
"hour 00–23": "時 00–23",
"hours": "小時",
"image": "影像",
"is": "是",
"is <": "小於",
"is >": "大於",
"is after": "晚於",
"is before": "早於",
"is between": "介於",
"is empty": "為空",
"is in the last": "在最近",
"is unknown": "未知",
"is ≤": "小於或等於",
"is ≥": "大於或等於",
"isn't": "不是",
"isn't empty": "不為空",
"isn't in the last": "不在最近",
"keep names — or e.g. {date}_{seq:3}": "保留名稱 — 或例如 {date}_{seq:3}",
"latitude, longitude": "緯度, 經度",
"memory only": "僅記憶體",
"minute": "分",
"month 01–12": "月 01–12",
"months": "個月",
"none": "無",
"none (CPU rendering)": "無(CPU 算繪)",
"none of": "皆不符合",
"not available here": "此處無法使用",
"of the following rules:": "下列規則:",
"or": "或",
"pick": "精選",
"previews": "預覽",
"publicDomain": "公有領域",
"purple": "紫色",
"raw": "RAW",
"red": "紅色",
"reject": "排除",
"saved in": "儲存於",
"second": "秒",
"smart previews": "智慧型預覽",
"starts with": "開頭為",
"the photos in view": "檢視中的照片",
"the selected photos": "選取的照片",
"this photo": "這張照片",
"true": "是",
"unknown": "未知",
"video": "影片",
"weeks": "週",
"year, 2 digits": "年,2 位數",
"year, 4 digits": "年,4 位數",
"years": "年",
"yellow": "黃色",
"{date} is the capture time; a photo without one uses the time it was imported.": "{date} 是拍攝時間;沒有拍攝時間的照片會使用匯入時間。",
"© 2026 Your Name": "© 2026 你的名字",
"© Your Name": "© 你的名字",
"− harder border · + softer border": "− 邊緣較硬 · + 邊緣較柔和",
"≤ at most": "≤ 最多",
"≥ at least": "≥ 至少",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"In Recently Deleted": "在「最近刪除」中",
+19 -15
View File
@@ -125,7 +125,7 @@ impl Contributor {
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
format!(
crate::i18n::tr_format!(
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
self.login,
self.prs,
@@ -136,7 +136,7 @@ impl Contributor {
self.binary_added,
self.binary_deleted,
day(self.first_commit),
day(self.last_commit),
day(self.last_commit)
)
}
}
@@ -232,43 +232,43 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
ui.label(crate::i18n::tr("Show"));
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
if ui.selectable_label(v.names == m, crate::i18n::tr(m.label())).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
ui.label(crate::i18n::tr("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
ui.separator();
let r = ui.selectable_label(!v.table, "Grab bag");
let r = ui.selectable_label(!v.table, crate::i18n::tr("Grab bag"));
crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect);
if r.clicked() {
v.table = false;
}
let r = ui.selectable_label(v.table, "Table");
let r = ui.selectable_label(v.table, crate::i18n::tr("Table"));
crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect);
if r.clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.separator();
egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
ui.label(crate::i18n::tr("No contributor data was built into this copy."));
} else if v.table {
table(app, ui, &list, &mut v);
} else {
@@ -289,7 +289,11 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if ui
.button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong())
.on_hover_text(crate::i18n::tr(k.label().0))
.clicked()
{
if v.key == k {
v.ascending = !v.ascending;
} else {
@@ -318,14 +322,14 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
ui.label(crate::i18n::tr("No model credits were built into this copy."));
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
ui.label(RichText::new(crate::i18n::tr(h)).strong());
}
ui.end_row();
for m in MODELS {
+1 -1
View File
@@ -122,7 +122,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.export = None;
app.toast(ctx, "Export stopped unexpectedly");
app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));
}
}
}
+20
View File
@@ -1107,6 +1107,26 @@ mod tests {
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importSource"), "source shown");
assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved");
// The source facts were cached by a worker with the source-language default. They must
// still be rendered in German after switching, with the folder name kept verbatim.
assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true);
h.settle(SETTLE);
fn texts(shape: &egui::epaint::Shape, out: &mut Vec<String>) {
match shape {
egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()),
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)),
_ => {}
}
}
let mut painted = Vec::new();
for shape in &h.view.shapes {
texts(&shape.shape, &mut painted);
}
assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}");
for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] {
assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}");
}
assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true);
// a camera / card folder: copied into the library by default
h.app.ui.dialog = None;
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);
+235 -1
View File
@@ -121,6 +121,8 @@ language_table! {
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
@@ -287,6 +289,68 @@ pub fn display_time(iso: &str) -> String {
}
}
/// Built-in colour names are translated; custom label names are user data.
pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_catalog::ColorLabel) -> String {
catalog.custom_label_name(label).map_or_else(|| tr(&catalog.label_name(label)).to_string(), str::to_string)
}
/// A legacy smart-album filter summary, using the same display labels as filter chips.
pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String {
if language() == Locale::En {
filter.describe()
} else {
lightcraft_engine::filter_chips(filter, catalog)
.iter()
.map(|chip| crate::panels::chips::display_label(chip, filter, catalog))
.collect::<Vec<_>>()
.join(" · ")
}
}
/// A rule summary for display, keeping free-text rule values verbatim.
pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String {
fn describe(rules: &lightcraft_catalog::RuleSet, depth: usize) -> String {
use lightcraft_catalog::{
Match, Rule,
rules::{FIELDS, Kind, ops_for},
};
if depth >= 16 {
return tr("Nested group").to_string();
}
let parts: Vec<String> = rules
.rules
.iter()
.map(|rule| match rule {
Rule::Group { group } => format!("({})", describe(group, depth + 1)),
Rule::Field { field, op, value } => {
let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() };
let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1);
let text = match kind {
Kind::Choice(_) | Kind::Bool => {
tr(value.as_str().unwrap_or(if value.as_bool() == Some(true) { "true" } else { "false" })).to_string()
}
_ if matches!(op.as_str(), "inLast" | "notInLast") => format!(
"{} {}",
value.get("n").unwrap_or(&serde_json::Value::Null),
tr(value.get("unit").and_then(serde_json::Value::as_str).unwrap_or("days"))
),
_ => value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }),
};
format!("{} {} {text}", tr(label), tr(operator)).trim().to_string()
}
})
.collect();
let join = match rules.mode {
Match::All => tr("and"),
Match::Any | Match::None => tr("or"),
};
let text = parts.join(&format!(" {join} "));
if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text }
}
// Preserve the established source-language summary.
if language() == Locale::En { rules.describe() } else { describe(rules, 0) }
}
#[cfg(test)]
mod tests {
use super::*;
@@ -359,7 +423,13 @@ mod tests {
assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("de"), None);
for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}");
}
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}");
}
assert_eq!(Locale::parse_tag("xx"), None);
}
#[test]
@@ -481,6 +551,8 @@ mod tests {
("app.language.simplifiedChinese", Locale::ZhHans),
("app.language.japanese", Locale::Ja),
("app.language.portuguese", Locale::PtBr),
("app.language.german", Locale::De),
("app.language.russian", Locale::Ru),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
@@ -515,6 +587,168 @@ mod tests {
}
}
/// Every command, control, rule and rename label, and every line of What's New, is a message a
/// catalog can translate. What a language lacks is reported, not failed (like
/// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every
/// language, and the translations catch up at their own pace.
#[test]
fn display_label_gaps_are_reported() {
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label)));
labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label));
for (_, label, kind) in lightcraft_catalog::rules::FIELDS {
labels.push(label);
labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label));
}
labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning));
labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES);
let ui_labels = labels.len();
for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) {
if line.is_empty() || line.starts_with("# ") {
continue;
}
let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line);
labels.push(label);
}
for language in Locale::ALL.iter().filter(|language| **language != Locale::En) {
let catalog = language.catalog();
let missing: Vec<&&str> = labels[..ui_labels].iter().filter(|label| !catalog.contains_key(**label)).collect();
let notes = labels[ui_labels..].iter().filter(|label| !catalog.contains_key(**label)).count();
if !missing.is_empty() || notes > 0 {
eprintln!(
"{} lacks {} display label(s) (shown in English): {missing:?}; and {notes} What's New line(s)",
language.code(),
missing.len()
);
}
}
// the German catalog, which set out to cover them all, still covers every other catalog
let german = Locale::De.catalog();
let gaps: Vec<&String> =
Locale::ALL.iter().flat_map(|language| language.catalog().keys()).filter(|key| !german.contains_key(key.as_str())).collect();
if !gaps.is_empty() {
eprintln!("de lacks {} message(s) another catalog has: {gaps:?}", gaps.len());
}
}
#[test]
fn german_switches_via_menu_and_control_persists_and_formats_dates() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.german", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::De);
assert_eq!(tr("File"), "Datei");
assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true));
assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026");
assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20.");
assert_eq!(date_group_label("2026-09", false), "09/2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05");
assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert");
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert");
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"de""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::De);
let ctx = egui::Context::default();
for code in ["de-DE", "de_AT.UTF-8", "de-CH"] {
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::De);
}
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
let rules = lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::Match::All,
rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }],
};
assert_eq!(rules_label(&rules), "Stichwörter enthält Color");
let filter = lightcraft_catalog::Filter {
labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue],
..Default::default()
};
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau");
app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap();
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau");
set_language(Locale::En);
}
#[test]
fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() {
let ctx = fonts_ctx(&[]);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}");
app.ui.view = crate::state::ViewMode::People;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Benannte Personen"), "{text}");
for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] {
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains(title), "{command}: {text}");
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "ÄÖÜäöüß„“".chars() {
assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}");
}
}
});
set_language(Locale::En);
}
#[test]
fn german_buttons_fit_the_bottom_bar_and_export_dialog() {
fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(shape) => {
bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size())))
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)),
_ => {}
}
}
let ctx = fonts_ctx(&[]);
let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.language = Locale::De;
let mut bounds = Vec::new();
for export in [false, true] {
if export {
app.run("dialog.export", serde_json::json!({})).unwrap();
}
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
bounds.clear();
for shape in out.shapes {
collect(&shape.shape, &mut bounds);
}
}
let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1;
if export {
let dialog = rect("dialog:window");
for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] {
assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id));
}
} else {
let button = rect("button:copySettings");
let (_, text) = bounds.iter().find(|entry| entry.0 == "Bearbeitungseinstellungen kopieren").unwrap();
assert!(button.contains_rect(*text), "German copy caption overflows its button: {text:?} vs {button:?}");
assert!(!button.intersects(rect("icon:copyGear")));
}
}
set_language(Locale::En);
}
/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are.
#[test]
fn menu_labels_translate_but_user_names_survive() {
+89 -35
View File
@@ -444,7 +444,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(_) => {
app.scan = None;
app.toast(ctx, "Scan failed");
app.toast(ctx, crate::i18n::tr("Scan failed"));
return;
}
};
@@ -466,7 +466,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
if out.candidates.is_empty() {
app.toast(ctx, "No photos found");
app.toast(ctx, crate::i18n::tr("No photos found"));
return;
}
app.renderer.forget_imports();
@@ -602,7 +602,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
Ok(run) => task.run = Some(run),
Err(e) => {
log::warn!("import: {e}");
app.toast(ctx, format!("Import failed: {e}"));
app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e));
return;
}
}
@@ -680,7 +680,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
app.session.merge_undo(steps, &label);
if task.auto {
if task.imported > 0 {
app.toast(ctx, format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
}
return;
}
@@ -710,7 +710,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
if task.params["mode"] == "move" {
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
if task.kept > 0 {
msg.push_str(&format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
}
}
if task.duplicates > 0 {
@@ -854,9 +854,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
// (whether each source is a folder is checked off the UI thread, once)
let joined = d.sources.join("\n");
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
source_summary(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
inspect_sources(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
})
.unwrap_or_else(|| format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
.map(|summary| summary.display())
.unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
register(ui.ctx(), "label:importSource", r.rect);
});
@@ -892,7 +893,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
let r = ui.label(
egui::RichText::new(match (d.copy, d.move_files) {
egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) {
(true, false) => {
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
}
@@ -900,7 +901,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
"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."
}
_ => "Add in place: the library references the files where they are; nothing is copied or moved.",
})
}))
.color(if d.copy && d.move_files { t.caution } else { t.text_dim })
.small(),
);
@@ -908,7 +909,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if d.copy {
field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
@@ -929,9 +930,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (k, label) in opts {
let r = ui.selectable_label(key == k, label);
let r = ui.selectable_label(key == k, crate::i18n::tr(label));
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
if r.clicked() {
d.organize = k.to_string();
@@ -947,7 +948,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let folders_id = egui::Id::new("import-folder-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
register(ui.ctx(), "field:importFolderTemplate", r.rect);
tag_toggle(ui, "importFolders")
@@ -979,8 +980,13 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let rename_id = egui::Id::new("import-rename");
let tags_open = field(ui, "Rename", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w));
let w = tag_field_width(ui);
let r = ui.add(
egui::TextEdit::singleline(&mut d.rename)
.id(rename_id)
.hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}"))
.desired_width(w),
);
register(ui.ctx(), "field:importRename", r.rect);
tag_toggle(ui, "importRename")
});
@@ -1034,8 +1040,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
field(ui, "Album", |ui| {
let cur = match d.album {
Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(),
None if !d.new_album.is_empty() => "New album".into(),
None => "None".into(),
None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(),
None => crate::i18n::tr("None").into(),
};
egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
@@ -1045,7 +1051,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
d.album = None;
if d.new_album.is_empty() {
d.new_album = "Imported Photos".into();
d.new_album = crate::i18n::tr("Imported Photos").into();
}
}
for (id, name) in &albums {
@@ -1061,13 +1067,25 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
field(ui, "Preset", |ui| {
let cur = app.session.presets.iter().find(|p| p.id == d.preset).map(|p| p.name.clone()).unwrap_or_else(|| "None".into());
let cur = app
.session
.presets
.iter()
.find(|p| p.id == d.preset)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| crate::i18n::tr("None").into());
egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.preset.clear();
}
for p in &app.session.presets {
if ui.selectable_label(d.preset == p.id, format!("{} — {}", p.group, p.name)).clicked() {
if ui
.selectable_label(
d.preset == p.id,
format!("{} — {}", crate::i18n::builtin_label(&p.group, p.builtin), crate::i18n::builtin_label(&p.name, p.builtin)),
)
.clicked()
{
d.preset = p.id.clone();
}
}
@@ -1075,7 +1093,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
});
if !app.session.metadata_presets.is_empty() {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() };
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.metadata_preset.clear();
@@ -1161,6 +1179,13 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
}
}
/// Reserve the actual translated tag button width beside a template field.
pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text);
(ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0)
}
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
/// ([`tag_help`]) is open.
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
@@ -1180,7 +1205,12 @@ pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
let unknown = lightcraft_engine::rename::unknown_tokens(template);
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
let text = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" });
let text = crate::i18n::tr_format!(
"Unknown tag{s} {} stay{} as typed (see Tags)",
unknown.join(" "),
if unknown.len() == 1 { "s" } else { "" },
s = s
);
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
}
}
@@ -1222,22 +1252,22 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
if tok.aliases.is_empty() {
"Insert at the cursor".to_string()
} else {
format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
},
);
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
if r.clicked() {
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
}
ui.label(egui::RichText::new(tok.meaning).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label));
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
ui.end_row();
}
});
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect();
ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect();
ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
for n in TEMPLATE_NOTES {
ui.label(egui::RichText::new(format!("• {n}")).color(t.text_dim).small());
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small());
}
});
});
@@ -1284,17 +1314,41 @@ pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<Stri
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
pub fn source_summary(sources: &[String]) -> String {
inspect_sources(sources).display()
}
/// Facts cached by the filesystem worker, formatted in the UI thread's active language.
/// Caching a translated String on the worker would always use its default language (English)
/// and also retain that language after the user changes the UI language.
#[derive(Clone)]
enum SourceSummary {
Folder(String),
File(String),
Many { files: usize, folders: usize },
}
impl SourceSummary {
fn display(&self) -> String {
match self {
Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name),
Self::File(name) => name.clone(),
Self::Many { files, folders } => match (files, folders) {
(_, 0) => crate::i18n::tr_format!("{} files", files),
(0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
(_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f),
},
}
}
}
fn inspect_sources(sources: &[String]) -> SourceSummary {
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
match sources {
[one] if std::path::Path::new(one).is_dir() => format!("Folder “{}” (and its subfolders)", name(one)),
[one] => name(one),
[one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)),
[one] => SourceSummary::File(name(one)),
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
match folders {
0 => crate::i18n::tr_format!("{} files", many.len()),
f if f == many.len() => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
f => crate::i18n::tr_format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }, f = f),
}
SourceSummary::Many { files: many.len().saturating_sub(folders), folders }
}
}
}
@@ -1305,7 +1359,7 @@ fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(78.0);
ui.label(egui::RichText::new(label).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label));
});
add(ui)
})
+18 -8
View File
@@ -332,11 +332,13 @@ impl LightcraftApp {
let plural = if done == 1 { "" } else { "s" };
let mut msg = match (b.error(), b.what) {
(Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }),
(None, "") => format!("Previews ready for {done} photo{plural}"),
(None, what) => format!("Done ({what}): {done} photo{plural}"),
(None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural),
(None, what) => {
crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what))
}
};
if failed > 0 {
msg.push_str(&format!(" · {failed} couldn't be rendered"));
msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed));
}
self.toast(ctx, msg);
}
@@ -344,8 +346,8 @@ impl LightcraftApp {
if self.ui.preview_build_seen != Some((key, false)) {
self.ui.preview_build_seen = Some((key, false));
let msg = match b.what {
"" => format!("Building previews for {} photos…", b.total),
what => format!("Working on {what} for {} photos…", b.total),
"" => crate::i18n::tr_format!("Building previews for {} photos…", b.total),
what => crate::i18n::tr_format!("Working on {what} for {} photos…", b.total, what = crate::i18n::tr(what)),
};
self.toast(ctx, msg);
}
@@ -361,8 +363,16 @@ impl LightcraftApp {
(Some((n, e)), false) => {
self.ui.unsaved_seen = true;
let t = ctx.input(|i| i.time);
let what = if n == 1 { "1 change".to_string() } else { format!("{n} changes") };
self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0, None));
self.ui.toast = Some((
crate::i18n::tr_format!(
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
if n == 1 { "" } else { "s" },
n = n,
e = e
),
t + 6.0,
None,
));
}
(None, true) => {
self.ui.unsaved_seen = false;
@@ -415,7 +425,7 @@ impl LightcraftApp {
(Some(e), false) if e.contains("cancelled") => {
self.toast(ctx, crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time."))
}
(Some(e), false) => self.toast_error(ctx, format!("The SAM 3 download failed: {e}")),
(Some(e), false) => self.toast_error(ctx, crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e)),
(None, false) if download.finished => {
self.toast_error(ctx, crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready."))
}
+13 -6
View File
@@ -361,19 +361,26 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.buildPreviews",
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
format!("Build Standard-Sized Previews ({scope})"),
crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
item(
"library.buildPreviews",
json!({"size": "full"}),
crate::i18n::tr_format!("Build 1:1 Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
MenuNode::Separator,
// (read and written on a worker thread: the originals may be on a slow drive)
item(
"library.smartPreviews",
json!({"background": true}),
format!("Build Smart Previews ({scope})"),
crate::i18n::tr_format!("Build Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@@ -381,7 +388,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.smartPreviews",
json!({"discard": true, "background": true}),
format!("Discard Smart Previews ({scope})"),
crate::i18n::tr_format!("Discard Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@@ -414,7 +421,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
.map(|(l, sc)| {
let name = format!("{l:?}");
let label = match app.session.catalog.custom_label_name(*l) {
Some(custom) => format!("{custom} ({name})"),
Some(custom) => format!("{custom} ({})", crate::i18n::tr(&name)),
None => name.clone(),
};
item(
@@ -438,7 +445,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
v.push(item(
"label.applySet",
json!({"name": name}),
format!("Label Set: {name}"),
format!("{}: {name}", crate::i18n::tr("Label Set")),
None,
true,
Some(current.as_deref() == Some(name)),
+37 -13
View File
@@ -18,6 +18,8 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
@@ -35,6 +37,8 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
"app.language.portuguese" => Some(crate::i18n::Locale::PtBr),
"app.language.german" => Some(crate::i18n::Locale::De),
"app.language.russian" => Some(crate::i18n::Locale::Ru),
_ => None,
}
}
@@ -181,10 +185,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
app.ui.right = RightPanel::None;
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name));
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name)));
} else {
app.ui.right = p;
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name));
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name)));
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@@ -275,7 +279,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.view = ViewMode::PhotoGrid;
}
if let Some(k) = app.ui.keyword_painter.clone() {
app.toast(&ctx, format!("Painting “{k}”: click photos to add or remove it · Esc stops"));
app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k));
}
Ok(json!({"keyword": app.ui.keyword_painter}))
}
@@ -374,7 +378,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.zoom = Zoom::Fit;
app.ui.tool.clear();
let _ = app.session.end_interaction();
app.toast(&ctx, "Slideshow · Space pauses · Esc ends");
app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends"));
Ok(json!({"interval": interval}))
}
"view.fullScreenPreview" => {
@@ -915,10 +919,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if let Some(f) = app.services.open_with.as_mut()
&& let Err(e) = f(&path, &editor)
{
app.toast(&ctx, format!("Couldn't open the editor: {e}"));
app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e));
}
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
app.toast(&ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name));
Ok(r)
}
"dialog.cull" => {
@@ -1020,8 +1024,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let n = v["found"].as_array().map_or(0, Vec::len);
let left = v["missing"].as_u64().unwrap_or(0);
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
app.toast(ctx, format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }));
let unsure = if unsure > 0 {
crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure)
} else {
String::new()
};
app.toast(
ctx,
crate::i18n::tr_format!(
"Found {n} missing photo{}; {left} still missing{unsure}",
if n == 1 { "" } else { "s" },
left = left,
n = n,
unsure = unsure
),
);
app.ui.last_find_missing = Some(v);
}
Err(e) => app.toast(ctx, e),
@@ -1066,14 +1083,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if let Ok(v) = &r {
let n = v["tagged"].as_u64().unwrap_or(0);
let sk = &v["skipped"];
let mut msg = format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" });
let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n);
let outside = sk["outside"].as_u64().unwrap_or(0);
if outside > 0 {
msg += &format!("; {outside} outside its time range");
msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside);
}
let kept = sk["hasGps"].as_u64().unwrap_or(0);
if kept > 0 {
msg += &format!("; {kept} already had a location");
msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept);
}
app.toast(&egui::Context::default(), msg);
}
@@ -1154,7 +1171,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string())
};
if profiles > 0 {
app.toast(&ctx, format!("Imported {profiles} profile{} (Profile browser ▸ their groups)", if profiles == 1 { "" } else { "s" }));
app.toast(
&ctx,
crate::i18n::tr_format!(
"Imported {profiles} profile{} (Profile browser ▸ their groups)",
if profiles == 1 { "" } else { "s" },
profiles = profiles
),
);
}
if let Ok(v) = &r {
let n = v["imported"].as_array().map_or(0, Vec::len);
@@ -1182,7 +1206,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
skipped.dedup();
if !skipped.is_empty() {
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
msg += &format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
}
app.toast(&ctx, msg);
if n > 0 {
+1 -1
View File
@@ -320,7 +320,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
} else {
let ev: Vec<String> =
info["ev"].as_array().map(|a| a.iter().filter_map(Value::as_f64).map(|v| format!("{v:+.1}")).collect()).unwrap_or_default();
format!("{} photos · exposures {} EV", ev.len(), ev.join(" / "))
crate::i18n::tr_format!("{} photos · exposures {} EV", ev.len(), ev.join(" / "))
};
ui.label(egui::RichText::new(txt).color(t.text_dim));
}
+9 -14
View File
@@ -49,18 +49,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear.
const PILL_W: f32 = 196.0;
const CENTRE_W: f32 = PILL_W + 10.0 + 136.0 + 4.0 + 30.0;
/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the
/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill.
fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: f32) {
let t = Tokens::get(ui.ctx());
let has_clip = app.session.clipboard.is_some();
let label = crate::i18n::tr(if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" });
let copy_width = (ui.painter().layout_no_wrap(label.to_string(), t.font(13.0), t.text_label).size().x + 24.0).max(136.0);
let centre_width = PILL_W + 10.0 + copy_width + 4.0 + 30.0;
let room = to - from;
if room < PILL_W {
return;
}
let with_copy = room >= CENTRE_W;
let w = if with_copy { CENTRE_W } else { PILL_W };
let with_copy = room >= centre_width;
let w = if with_copy { centre_width } else { PILL_W };
// where it sits with room to spare (slightly left of centre), kept between the side groups
let left = (full.center().x - 60.0 - PILL_W / 2.0).clamp(from, to - w);
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
@@ -99,23 +102,15 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
if !with_copy {
return;
}
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0));
let has_clip = app.session.clipboard.is_some();
let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" };
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0));
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
register(ui.ctx(), "button:copySettings", copy_r);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
ui.painter().text(
copy_r.center(),
Align2::CENTER_CENTER,
crate::i18n::tr(label),
t.font(13.0),
if active.is_some() { t.text_label } else { t.text_disabled },
);
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
if cresp.clicked() && active.is_some() {
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
app.toast(ui.ctx(), msg);
app.toast(ui.ctx(), crate::i18n::tr(msg));
}
let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0));
let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click());
+55 -4
View File
@@ -28,6 +28,52 @@ fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 {
ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0
}
/// Localise generated chip labels without rewriting search text or metadata values.
pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String {
use crate::i18n::{date_group_label, rules_label, tr};
if crate::i18n::language() == crate::i18n::Locale::En {
return chip.label.clone();
}
if chip.clear.get("ruleSet").is_some()
&& let Some(rules) = &filter.rule_set
{
return format!("{}: {}", tr("Rules"), rules_label(rules));
}
if chip.clear.get("label").is_some() {
let mut labels = filter.labels.clone();
labels.extend(filter.label.filter(|label| !labels.contains(label)));
let names = labels.iter().map(|label| crate::i18n::color_label(catalog, *label)).collect::<Vec<_>>();
return format!("{}: {}", tr("Label"), names.join(&format!(" {} ", tr("or"))));
}
if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() {
return match (&filter.date_from, &filter.date_to) {
(Some(from), Some(to)) => format!("{} {} – {}", tr("Captured"), date_group_label(from, false), date_group_label(to, false)),
(Some(from), None) => format!("{} {}", tr("Captured from"), date_group_label(from, false)),
(None, Some(to)) => format!("{} {}", tr("Captured until"), date_group_label(to, false)),
_ => chip.label.clone(),
};
}
if chip.clear.get("only").is_some() {
return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = filter.only.len()));
}
if let Some((prefix, value)) = chip.label.split_once(": ") {
let value = if matches!(prefix, "Flag" | "Type") {
tr(value).to_string()
} else if prefix == "Date" {
filter.date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false))
} else if prefix == "Imported" {
filter.imported.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false))
} else {
value.to_string()
};
return format!("{}: {value}", tr(prefix));
}
if let Some(value) = chip.label.strip_prefix("Rating ") {
return format!("{} {value}", tr("Rating"));
}
tr(&chip.label).to_string()
}
/// Draws the strip; does nothing without chips.
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
if chips.is_empty() {
@@ -42,7 +88,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
let mut x = bar.left() + 20.0;
let mut shown = 0;
for (i, c) in chips.iter().enumerate() {
let w = chip_width(ui, &c.label, font.clone());
let label = display_label(c, &app.session.filter, &app.session.catalog);
let w = chip_width(ui, &label, font.clone());
// always show the first chip; keep room for the tail unless this is the last one that fits
let room = right - x - if i + 1 == chips.len() { 90.0 } else { TAIL };
if i > 0 && w > room {
@@ -51,7 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w.min(right - x), 22.0));
ui.painter().rect(r, 11.0, t.field, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), StrokeKind::Inside);
let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y));
ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &c.label, font.clone(), t.text);
ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &label, font.clone(), t.text);
let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0));
let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter"));
register(ui.ctx(), format!("chip:{i}"), xr);
@@ -77,10 +124,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
child.spacing_mut().item_spacing.x = 8.0;
let hidden = &chips[shown..];
if !hidden.is_empty() {
let more = crate::widgets::text_button(&mut child, "chipsMore", &format!("+{} more", hidden.len()), false);
let more = crate::widgets::text_button(&mut child, "chipsMore", &crate::i18n::tr_format!("+{} more", hidden.len()), false);
egui::Popup::menu(&more).show(|ui| {
for c in hidden {
if ui.button(format!("{} ✕", c.label)).on_hover_text(crate::i18n::tr("Remove this filter")).clicked() {
if ui
.button(format!("{} ✕", display_label(c, &app.session.filter, &app.session.catalog)))
.on_hover_text(crate::i18n::tr("Remove this filter"))
.clicked()
{
let _ = app.run("library.filter", c.clear.clone());
if c.clear.get("text").is_some() {
app.ui.search.clear();
+5 -4
View File
@@ -631,7 +631,7 @@ fn region_overlay(
let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() {
if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() {
edit = Some(RegionEdit::Remove(index));
}
}
@@ -668,8 +668,9 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
}
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let label = chips.iter().map(|c| c.label.as_str()).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(format!("Filtered: {label}"), t.font(12.0), Color32::from_gray(225));
let label =
chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225));
let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0));
let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0));
let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click());
@@ -681,7 +682,7 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Click to clear these filters").clicked() {
if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() {
let _ = app.run("library.clearFilter", json!({}));
}
}
+24 -26
View File
@@ -138,17 +138,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default();
let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" };
ui.label(
egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim),
egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim),
);
}
Dialog::Cull { reject_below, pick_best } => {
let n = app.session.selection.ids.len();
let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) };
ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label));
ui.add_space(6.0);
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0));
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0));
crate::widgets::register(ui.ctx(), "field:cullReject", r.rect);
ui.label(egui::RichText::new(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." }).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." })).color(t.text_dim));
let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst"));
crate::widgets::register(ui.ctx(), "check:cullPick", r.rect);
ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim));
@@ -159,15 +159,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let l = line.trim_end();
if let Some(h) = l.strip_prefix("### ") {
ui.add_space(6.0);
ui.label(egui::RichText::new(h).font(t.semibold(12.5)).color(t.text));
ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text));
} else if let Some(h) = l.strip_prefix("## ") {
ui.add_space(8.0);
ui.label(egui::RichText::new(h).font(t.semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text));
} else if l.starts_with("# ") || l.is_empty() {
} else if let Some(b) = l.strip_prefix("- ") {
ui.label(egui::RichText::new(format!("• {}", b.replace('`', ""))).color(t.text_label));
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label));
} else {
ui.label(egui::RichText::new(l.trim().replace('`', "")).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label));
}
}
});
@@ -175,7 +175,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::SystemInfo { rows } => {
egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| {
for (k, v) in rows.iter() {
ui.label(egui::RichText::new(k).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(k)).color(t.text_dim));
ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap());
ui.end_row();
}
@@ -214,7 +214,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
egui::ScrollArea::vertical().max_height(440.0).auto_shrink([false, true]).show(ui, |ui| {
if groups.is_empty() {
ui.label(egui::RichText::new(rows["note"].as_str().unwrap_or("No metadata found")).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim));
}
for (g, list) in &groups {
ui.add_space(6.0);
@@ -258,7 +258,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
let rules = app.session.view_rules();
let n = app.session.catalog.query(&rules, &Default::default()).len();
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", rules.describe())).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label));
ui.label(
egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n))
.color(t.text_dim),
@@ -354,7 +354,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let template_id = egui::Id::new("rename-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = crate::import::tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w));
crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect);
crate::import::tag_toggle(ui, "renameTemplate")
@@ -391,7 +391,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
None => {
ui.label(egui::RichText::new("Checking names…").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim));
}
}
let more = total.saturating_sub(RENAME_PREVIEW_ROWS);
@@ -408,15 +408,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
confirm = true;
}
ui.label(
egui::RichText::new(format!(
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
if n == 1 { "" } else { "s" }
))
egui::RichText::new(crate::i18n::tr_format!("Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
if n == 1 { "" } else { "s" }, from = from, n = n))
.color(t.text_dim),
);
}
Dialog::MergeKeywords { from, into } => {
ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<Vec<_>>().join(", "))).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<Vec<_>>().join(", "))).color(t.text_label));
let r = ui.add(egui::TextEdit::singleline(into).hint_text(crate::i18n::tr("Keyword")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect);
r.request_focus();
@@ -439,7 +437,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
Dialog::TextPrompt { value, hint, .. } => {
let r = ui.add(egui::TextEdit::singleline(value).hint_text(hint.as_str()).desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(value).hint_text(crate::i18n::tr(hint.as_str())).desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
@@ -622,7 +620,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
} else {
choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical);
field(ui, "Text", |ui| {
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY))
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY))
});
}
choices(
@@ -657,7 +655,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let naming_id = egui::Id::new("export-naming");
let tags_open = field(ui, "File name", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = crate::import::tag_field_width(ui);
ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w));
crate::import::tag_toggle(ui, "exportNaming")
});
@@ -756,7 +754,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.label(crate::i18n::tr_format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
@@ -917,7 +915,7 @@ pub fn fmt_gap(v: f64) -> String {
60..3600 => format!("{} min {} s", v / 60, v % 60),
3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600),
3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60),
_ => "1 day".into(),
_ => crate::i18n::tr("1 day").into(),
}
}
@@ -1106,7 +1104,7 @@ fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<String>) {
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
let key = key_of(g);
let mut on = groups.contains(&key);
let r = cols[i % 2].checkbox(&mut on, g.label());
let r = cols[i % 2].checkbox(&mut on, crate::i18n::tr(g.label()));
crate::widgets::register(&r.ctx, format!("{tag}:{key}"), r.rect);
if r.changed() {
if on {
@@ -1118,12 +1116,12 @@ fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<String>) {
}
});
ui.horizontal(|ui| {
let all = ui.small_button("All");
let all = ui.small_button(crate::i18n::tr("All"));
crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect);
if all.clicked() {
*groups = SettingsGroup::ALL.iter().map(key_of).collect();
}
let none = ui.small_button("None");
let none = ui.small_button(crate::i18n::tr("None"));
crate::widgets::register(ui.ctx(), format!("{tag}:none"), none.rect);
if none.clicked() {
groups.clear();
+5 -3
View File
@@ -99,7 +99,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.spacing_mut().item_spacing.x = 8.0;
if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() {
let _ = app.run("develop.auto", json!({}));
app.toast(ui.ctx(), "Auto settings applied");
app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied"));
}
let bw = crate::is_bw(&d);
if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() {
@@ -588,7 +588,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if r.clicked() {
let name = crate::i18n::tr_format!("Proof Copy ({})", app.ui.proof.space.label());
if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() {
app.toast(ui.ctx(), "Proof copy created");
app.toast(ui.ctx(), crate::i18n::tr("Proof copy created"));
}
}
});
@@ -1144,7 +1144,9 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) {
for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] {
let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0)));
crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect);
if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = label)).clicked() {
if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)))
.clicked()
{
let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d}));
}
}
+19 -3
View File
@@ -57,7 +57,13 @@ fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&e
/// A dropdown showing `current`; `items` are (label, filter patch, selected).
fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) {
let t = Tokens::get(ui.ctx());
let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), crate::i18n::tr(current), t.font(12.5), if active { t.text } else { t.text_label });
let r = crate::widgets::dropdown(
ui,
&format!("filter-{id}"),
if active && matches!(id, "camera" | "lens" | "keyword") { current } else { crate::i18n::tr(current) },
t.font(12.5),
if active { t.text } else { t.text_label },
);
if active {
ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside);
}
@@ -65,7 +71,17 @@ fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, a
egui::Popup::menu(&r).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (label, patch, sel) in &items {
if ui.selectable_label(*sel, crate::i18n::tr(label)).clicked() {
if ui
.selectable_label(
*sel,
if matches!(id, "camera" | "lens" | "keyword") && patch.get(id).is_some_and(|value| !value.is_null()) {
label.as_str()
} else {
crate::i18n::tr(label)
},
)
.clicked()
{
chosen = Some(patch.clone());
}
}
@@ -163,7 +179,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
for l in ColorLabel::ALL {
let on = chosen.contains(&l);
let name = format!("{l:?}").to_lowercase();
let tip = format!("{} label (click more labels to show any of them)", app.session.catalog.label_name(l));
let tip = crate::i18n::tr_format!("{} label (click more labels to show any of them)", crate::i18n::color_label(&app.session.catalog, l));
let resp = toggle(ui, &format!("label-{name}"), on, &tip, |p, r, _| {
p.circle_filled(r.center(), 5.5, label_color(l));
});
+9 -6
View File
@@ -482,15 +482,15 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
// screen readers: the file, then rating / flag / label
let mut spoken = photo.file_name.clone();
if photo.rating > 0 {
spoken.push_str(&format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" }));
spoken.push_str(&crate::i18n::tr_format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" }));
}
match photo.flag {
lightcraft_catalog::Flag::Pick => spoken.push_str(", picked"),
lightcraft_catalog::Flag::Reject => spoken.push_str(", rejected"),
lightcraft_catalog::Flag::Pick => spoken.push_str(crate::i18n::tr(", picked")),
lightcraft_catalog::Flag::Reject => spoken.push_str(crate::i18n::tr(", rejected")),
lightcraft_catalog::Flag::None => {}
}
if let Some(l) = photo.label {
spoken.push_str(&format!(", {} label", app.session.catalog.label_name(l)));
spoken.push_str(&crate::i18n::tr_format!(", {} label", crate::i18n::color_label(&app.session.catalog, l)));
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &spoken));
let active = state == lightcraft_engine::SelectionState::Active;
@@ -746,7 +746,7 @@ pub use super::filterbar::label_color;
pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label);
for l in ColorLabel::ALL {
let name = app.session.catalog.label_name(l);
let name = crate::i18n::color_label(&app.session.catalog, l);
let resp = ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
ui.painter().circle_filled(r.center(), 5.0, label_color(l));
@@ -808,7 +808,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
Ok(r) => {
let n = r["photos"].as_array().map_or(0, Vec::len);
app.ui.view = crate::state::ViewMode::PhotoGrid;
app.toast(ui.ctx(), format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }));
app.toast(
ui.ctx(),
crate::i18n::tr_format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }, n = n),
);
}
Err(e) => app.toast(ui.ctx(), e),
}
+5 -4
View File
@@ -295,7 +295,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
// (checked off the UI thread: a home folder can be on a network share)
if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) {
builtin.push((name.to_string(), p));
builtin.push((crate::i18n::tr(name).to_string(), p));
}
}
}
@@ -314,7 +314,8 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let reveal = local.owner == Some(i);
folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient);
}
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() {
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked()
{
let picked = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(path) = picked {
match app.run("library.browse", json!({"path": path})) {
@@ -585,7 +586,7 @@ fn folder_tree(
let into = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(into) = into {
match app.run("folder.move", json!({"path": path, "into": into})) {
Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])),
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])),
Err(e) => app.toast(ui.ctx(), e),
}
}
@@ -665,7 +666,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
drop_target(app, ui, &resp, a);
}
if let Some(rules) = &a.smart {
resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", rules.describe()));
resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)));
}
if resp.clicked() {
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
+9 -6
View File
@@ -103,7 +103,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// an empty colour range; clicking the photo samples it
let _ = app.run("mask.add", json!({"kind": "colorRange"}));
app.ui.tool = "colorRange".into();
app.toast(ui.ctx(), "Click the photo to pick a colour · ⇧-click adds more");
app.toast(ui.ctx(), crate::i18n::tr("Click the photo to pick a colour · ⇧-click adds more"));
}
"object" => start_object(app, ui.ctx(), "new"),
"prompt" => start_describe(app, "new"),
@@ -292,7 +292,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
return;
}
// a long name is cut short (with …) before the options button, not past the panel
let text = format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" });
let text = format!("{op}{label}{}", if c.invert { crate::i18n::tr(" (inverted)") } else { "" });
let room = (ui.available_width() - 30.0).max(0.0);
let resp = ui.scope(|ui| {
ui.set_max_width(room);
@@ -564,7 +564,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape
MaskShape::ColorRange { samples, refine } => {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("{} sample{}", samples.len(), if samples.len() == 1 { "" } else { "s" }))
egui::RichText::new(crate::i18n::tr_format!("{} sample{}", samples.len(), if samples.len() == 1 { "" } else { "s" }))
.color(t.text_dim)
.size(11.5),
);
@@ -657,7 +657,7 @@ pub(crate) fn start_object(app: &mut LightcraftApp, ctx: &egui::Context, op: &st
match r {
Ok(_) => {
app.ui.tool = "object".into();
app.toast(ctx, "Click the object to select it · ⌥-click leaves a part out");
app.toast(ctx, crate::i18n::tr("Click the object to select it · ⌥-click leaves a part out"));
}
Err(e) => app.ai_error(ctx, e, Some(("object", op))),
}
@@ -707,8 +707,11 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
ui.label(crate::i18n::tr(prompt));
let mut submit = false;
ui.horizontal(|ui| {
let r =
ui.add(egui::TextEdit::singleline(&mut text).hint_text("e.g. sky · the red car · car, road").desired_width(ui.available_width() - 64.0));
let r = ui.add(
egui::TextEdit::singleline(&mut text)
.hint_text(crate::i18n::tr("e.g. sky · the red car · car, road"))
.desired_width(ui.available_width() - 64.0),
);
register(ui.ctx(), "maskDescribe", r.rect);
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
r.request_focus();
+8 -6
View File
@@ -29,10 +29,12 @@ pub fn may_close(app: &mut LightcraftApp) -> bool {
match app.session.unsaved() {
None => true,
Some((n, e)) => {
app.quit_prompt = Some(format!(
"{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
if n == 1 { "1 change".to_string() } else { format!("{n} changes") },
if n == 1 { "it is" } else { "they are" }
app.quit_prompt = Some(crate::i18n::tr_format!(
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
if n == 1 { "" } else { "s" },
if n == 1 { "it is" } else { "they are" },
n = n,
e = e
));
false
}
@@ -55,7 +57,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
});
let mut chosen = None;
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
egui::Window::new(title)
egui::Window::new(crate::i18n::tr(title))
.id(egui::Id::new(id))
.order(egui::Order::Foreground)
.collapsible(false)
@@ -71,7 +73,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
ui.add_space(4.0);
ui.horizontal(|ui| {
for (i, (bid, label, _)) in buttons.iter().enumerate() {
let r = ui.add(egui::Button::new(*label).min_size(vec2(0.0, 26.0)));
let r = ui.add(egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0)));
register(ui.ctx(), format!("button:{bid}"), r.rect);
if r.clicked() {
chosen = Some(i);
+3 -3
View File
@@ -22,7 +22,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let people = app.caches.people(&app.session.catalog, &app.session.filter);
let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover());
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, "Named People", t.semibold(15.0), t.text);
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text);
ui.painter().text(pos2(head.right() - PAD, head.center().y), Align2::RIGHT_CENTER, people.len().to_string(), t.font(13.0), t.text_dim);
// the filters narrowing the list (a date, a keyword…), removable here
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
@@ -78,9 +78,9 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, pp
let name =
if person.name.chars().count() > 19 { format!("{}…", person.name.chars().take(18).collect::<String>()) } else { person.name.clone() };
p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text);
let photos = if person.count == 1 { "1 photo".to_string() } else { format!("{} photos", person.count) };
let photos = crate::i18n::tr_format!("{n} photo{}", if person.count == 1 { "" } else { "s" }, n = person.count);
p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim);
if resp.on_hover_text(format!("{} — show their photos", person.name)).clicked() {
if resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)).clicked() {
let _ = app.run("library.filter", json!({"person": person.name}));
let _ = app.run("view.photoGrid", json!({}));
}
+55 -39
View File
@@ -437,7 +437,15 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let i = app.ui.eye;
let eye = d.red_eye[i];
divider(ui);
super::edit::sub_title(ui, &format!("{} {} of {n}", if eye.pet { "Pet Eye" } else { "Red Eye" }, i + 1));
super::edit::sub_title(
ui,
&crate::i18n::tr_format!(
"{label} {index} of {n}",
label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }),
index = i + 1,
n = n
),
);
for k in ["pupilSize", "darken"] {
let cid = format!("redEye.{i}.{k}");
let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue };
@@ -478,8 +486,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(6.0);
}
if let Some(name) = &p.copy_name {
let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into());
ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label));
let of = p
.copy_of
.and_then(|m| app.session.catalog.photo(m))
.map(|m| m.file_name.clone())
.unwrap_or_else(|| crate::i18n::tr("a removed photo").into());
ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label));
}
ui.add_space(8.0);
if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner {
@@ -497,7 +509,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if on {
ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text));
}
let resp = resp.on_hover_text(app.session.catalog.label_name(l));
let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l));
if resp.clicked() {
let _ = app.run("photo.label", json!({"label": if on { "none".to_string() } else { key }}));
}
@@ -528,7 +540,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
meta_field(app, ui, label, key, value, lines);
}
// file: name (rename), path (reveal), capture time (edit)
let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim));
let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim));
small(ui, "File Name");
ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() {
@@ -543,7 +555,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
let path = match &p.source {
lightcraft_catalog::Source::File { path } => path.clone(),
lightcraft_catalog::Source::Demo { .. } => "Generated demo photo".into(),
lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(),
};
ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate());
if crate::menus::ui_enabled(app, "app.showInFinder") {
@@ -581,7 +593,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let gid = egui::Id::new("info-gps");
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("latitude, longitude").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY));
register(ui.ctx(), "field:gps", r.rect);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(gid, text.clone()));
@@ -610,9 +622,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
}
if let Some(a) = p.analysis {
let mut line = format!("Focus {:.0}", a.sharpness);
let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness);
if a.clipped > 0.02 {
line.push_str(&format!(" · {:.0}% clipped", a.clipped * 100.0));
line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0));
}
if a.group.is_some() {
line.push_str(if a.best { " · best of its burst" } else { " · in a burst" });
@@ -638,7 +650,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
_ => "Smart preview available",
};
ui.add_space(6.0);
ui.label(egui::RichText::new(text).color(if online { t.text_dim } else { t.accent }));
ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent }));
}
}
ui.add_space(10.0);
@@ -675,7 +687,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) {
});
let rows = [
("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))),
("Shutter speed", (!m.shutter.is_empty()).then(|| format!("{} sec", m.shutter))),
("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))),
("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))),
("ISO", m.iso.map(|i| i.to_string())),
];
@@ -683,7 +695,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) {
ui.add_space(6.0);
egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| {
for (k, v) in rows {
ui.label(dim(k));
ui.label(dim(crate::i18n::tr(k)));
ui.label(dim(&v.unwrap_or_else(|| "—".into())));
ui.end_row();
}
@@ -832,30 +844,35 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
let current = sets["current"].as_str().unwrap_or_default().to_string();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim));
egui::ComboBox::from_id_salt("kw-set").selected_text(&current).show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
if ui.selectable_label(name == current, name).clicked() {
let _ = app.run("keyword.useSet", json!({"name": name}));
egui::ComboBox::from_id_salt("kw-set")
.selected_text(crate::i18n::builtin_label(&current, current == lightcraft_engine::cmd::keywords::RECENT))
.show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
if ui
.selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT))
.clicked()
{
let _ = app.run("keyword.useSet", json!({"name": name}));
}
}
}
ui.separator();
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: "Save Keyword Set".into(),
hint: "Set name".into(),
value: String::new(),
command: "keyword.saveSet".into(),
params: json!({}),
key: "name".into(),
});
}
if current != lightcraft_engine::cmd::keywords::RECENT
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
{
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
ui.separator();
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: "Save Keyword Set".into(),
hint: "Set name".into(),
value: String::new(),
command: "keyword.saveSet".into(),
params: json!({}),
key: "name".into(),
});
}
if current != lightcraft_engine::cmd::keywords::RECENT
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
{
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
});
let kws: Vec<String> = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
if kws.is_empty() {
@@ -950,8 +967,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
} else if resp.clicked() && !current {
let _ = app.run("version.restore", json!({"index": i}));
}
resp.on_hover_text(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }).context_menu(
|ui| {
resp.on_hover_text(crate::i18n::tr(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }))
.context_menu(|ui| {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
@@ -968,8 +985,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.button(crate::i18n::tr("Delete")).clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
},
);
});
}
});
}
+30 -24
View File
@@ -34,9 +34,9 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) {
Match::Any => "any",
Match::None => "none",
};
egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(label(*m)).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| {
for x in [Match::All, Match::Any, Match::None] {
ui.selectable_value(m, x, label(x));
ui.selectable_value(m, x, crate::i18n::tr(label(x)));
}
});
}
@@ -47,7 +47,7 @@ fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &s
Value::Null => String::new(),
other => other.to_string(),
};
let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(hint).desired_width(width));
let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(crate::i18n::tr(hint)).desired_width(width));
register(ui.ctx(), format!("field:{salt}"), r.rect);
if r.changed() {
*v = json!(s);
@@ -83,9 +83,9 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
v["n"] = json!(n.round());
}
let unit = v["unit"].as_str().unwrap_or("days").to_string();
egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(&unit).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| {
for u in ["hours", "days", "weeks", "months", "years"] {
if ui.selectable_label(unit == u, u).clicked() {
if ui.selectable_label(unit == u, crate::i18n::tr(u)).clicked() {
v["unit"] = json!(u);
}
}
@@ -109,9 +109,9 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
(Some(Kind::Number), _) => number_value(ui, v, field),
(Some(Kind::Choice(c)), _) => {
let cur = v.as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(90.0).selected_text(&cur).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(90.0).selected_text(crate::i18n::tr(&cur)).show_ui(ui, |ui| {
for x in c.iter() {
if ui.selectable_label(cur == *x, *x).clicked() {
if ui.selectable_label(cur == *x, crate::i18n::tr(x)).clicked() {
*v = json!(x);
}
}
@@ -119,10 +119,13 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
}
(Some(Kind::Bool), _) => {
let mut b = v.as_bool().unwrap_or(true);
egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(if b { "true" } else { "false" }).show_ui(ui, |ui| {
ui.selectable_value(&mut b, true, "true");
ui.selectable_value(&mut b, false, "false");
});
egui::ComboBox::from_id_salt(format!("{salt}-bool"))
.width(60.0)
.selected_text(crate::i18n::tr(if b { "true" } else { "false" }))
.show_ui(ui, |ui| {
ui.selectable_value(&mut b, true, crate::i18n::tr("true"));
ui.selectable_value(&mut b, false, crate::i18n::tr("false"));
});
*v = json!(b);
}
(Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt),
@@ -156,22 +159,25 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
Rule::Field { field, op, value } => {
ui.horizontal(|ui| {
let label = FIELDS.iter().find(|f| f.0 == field.as_str()).map_or(field.as_str(), |f| f.1).to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(label).show_ui(ui, |ui| {
for (id, l, k) in FIELDS {
if ui.selectable_label(field == id, *l).clicked() && field != id {
*field = id.to_string();
if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) {
*op = ops_for(*k)[0].0.to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(
ui,
|ui| {
for (id, l, k) in FIELDS {
if ui.selectable_label(field == id, crate::i18n::tr(l)).clicked() && field != id {
*field = id.to_string();
if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) {
*op = ops_for(*k)[0].0.to_string();
}
*value = default_value(field, op);
}
*value = default_value(field, op);
}
}
});
},
);
let kind = field_kind(field).unwrap_or(Kind::Text);
let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(op_label).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| {
for (id, l) in ops_for(kind) {
if ui.selectable_label(op == id, *l).clicked() && op != id {
if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id {
let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty");
*op = id.to_string();
if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") {
@@ -209,12 +215,12 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
}
if rs.rules.is_empty() || depth == 0 {
ui.horizontal(|ui| {
let add = ui.small_button("+ Rule");
let add = ui.small_button(crate::i18n::tr("+ Rule"));
register(ui.ctx(), format!("button:ruleAddEnd-{salt}"), add.rect);
if add.clicked() {
rs.rules.push(new_rule());
}
if depth < 3 && ui.small_button("+ Group").clicked() {
if depth < 3 && ui.small_button(crate::i18n::tr("+ Group")).clicked() {
rs.rules.push(Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } });
}
});
+24 -11
View File
@@ -145,20 +145,27 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
/// A preset picker: `None` = `none_label`. Returns the new choice when it changed.
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
let name = |pid: &str| app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| format!("{pid} (missing)"));
let text = current.map(name).unwrap_or_else(|| none_label.to_string());
let name = |pid: &str| {
app.session
.presets
.iter()
.find(|p| p.id == pid)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid))
};
let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string());
let mut out = None;
let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| {
if ui.selectable_label(current.is_none(), none_label).clicked() {
if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() {
out = Some(None);
}
let mut group = "";
for p in &app.session.presets {
if p.group != group {
group = &p.group;
ui.label(RichText::new(group).size(10.5).weak());
ui.label(RichText::new(crate::i18n::builtin_label(group, p.builtin)).size(10.5).weak());
}
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() {
out = Some(Some(p.id.clone()));
}
}
@@ -214,9 +221,15 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
};
let mut pick = None;
let label = match current {
Some(RAW_DEFAULT) => "Same as raw default".to_string(),
None => "LightCraft Default".to_string(),
Some(pid) => app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| pid.to_string()),
Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(),
None => crate::i18n::tr("LightCraft Default").to_string(),
Some(pid) => app
.session
.presets
.iter()
.find(|p| p.id == pid)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| pid.to_string()),
};
let id = format!("settingsCamera-{i}");
let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
@@ -227,7 +240,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
pick = Some(json!({"camera": cam, "preset": null}));
}
for p in &app.session.presets {
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() {
pick = Some(json!({"camera": cam, "preset": p.id}));
}
}
@@ -272,7 +285,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let mut pick = None;
let r = egui::ComboBox::from_id_salt("settingsMetaPreset")
.width(240.0)
.selected_text(cur.clone().unwrap_or_else(|| "None".into()))
.selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into()))
.show_ui(ui, |ui| {
if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() {
pick = Some(String::new());
@@ -306,7 +319,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, crate::i18n::tr("Auto Import"));
hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."));
row(ui, t, crate::i18n::tr("Watched folder"), |ui| {
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| "Off".into())).color(t.text));
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| crate::i18n::tr("Off").into())).color(t.text));
let can = app.services.pick_folder.is_some();
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…")));
register(ui.ctx(), "button:settingsAutoFolder", r.rect);
+22 -12
View File
@@ -182,8 +182,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label =
if n == "0" { "Rating cleared".to_string() } else { crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
let label = if n == "0" {
crate::i18n::tr("Rating cleared").to_string()
} else {
crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0)))
};
app.toast(ctx, label);
} else if let Some(l) = f.strip_prefix("label:") {
cull(app, "photo.label", json!({"label": l}), false);
@@ -199,9 +202,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
} else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, &f, json!({}), false);
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
_ => app.toast(ctx, "Unflagged"),
"photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
"photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
_ => app.toast(ctx, crate::i18n::tr("Unflagged")),
}
} else {
// in the full-screen preview (no panels) I cycles the info overlay instead
@@ -237,9 +240,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
if let Ok(r) = app.run("album.toggleTarget", json!({})) {
let n = app.session.targets(&json!({})).len();
let what = if n == 1 { "photo".to_string() } else { crate::i18n::tr_format!("{n} photos", n = n) };
let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n);
let name = r["name"].as_str().unwrap_or("Quick Collection").to_string();
app.toast(ctx, if r["added"] == true { format!("Added {what} to {name}") } else { format!("Removed {what} from {name}") });
app.toast(
ctx,
if r["added"] == true {
crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what)
} else {
crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what)
},
);
}
continue;
}
@@ -281,11 +291,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
app.toast(ctx, e);
}
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
"photo.unflag" => app.toast(ctx, "Unflagged"),
"edit.undo" => app.toast(ctx, "Undo"),
"edit.redo" => app.toast(ctx, "Redo"),
"photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
"photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
"photo.unflag" => app.toast(ctx, crate::i18n::tr("Unflagged")),
"edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")),
"edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")),
_ => {}
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(TryRecvError::Disconnected) => {
// the worker died (a panic, already logged)
let t = app.tasks.running.remove(i);
app.toast(ctx, format!("{} failed", t.label));
app.toast(ctx, crate::i18n::tr_format!("{} failed", t.label));
}
}
}
+3 -4
View File
@@ -33,9 +33,8 @@ pub fn preview_only_variant(reason: &str) -> &str {
/// What a preview-only raw means for the user (see `Photo::preview_only`).
pub fn preview_only_explanation(reason: &str) -> String {
format!(
"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.",
crate::i18n::tr_format!(
"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.",
preview_only_variant(reason)
)
}
@@ -61,7 +60,7 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) {
})
.response;
let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Preview only: editing the camera's embedded JPEG"));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG")));
register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect);
}
+21 -8
View File
@@ -1,10 +1,10 @@
# Sony ARW, Nikon NEF and Panasonic RW2 starting look
# Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF starting look
ARW and NEF decoding supply a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2 and RAF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. At least 256 paired pixels, 5% coloured samples and a nonsingular input covariance with bounded condition are required.
A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours.
Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels).
@@ -20,7 +20,7 @@ One third of the pixels are held out from both fits. The combined model must red
Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders.
Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs now keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Measured/absolute-Kelvin Sony WB remains future camera-calibration work.
Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. Measured/absolute-Kelvin WB remains future camera-calibration work.
Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust.
@@ -46,14 +46,27 @@ The same fit, gates and relative WB apply to Panasonic RW2, Leica RWL and the ol
Public CC0 samples from raw.pixls.us (178 files from 118 Panasonic and Leica bodies; the four from 2005–2007 carry no preview), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 140 of 174 fits accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 (1.2–21.3) with matching mean lightness (median |ΔL*| 0.4); 124 of them use a hue/saturation table. (Fitted with the matrix and tone curve alone, 137 were accepted at median ΔE 6.1; every one of those is as close or closer with the table and chroma curve, by 0.75 on average.) On the 14 corpus files with a preview the neutral fallback scored ΔE 9.6–27 and rendered up to 16 L* too dark; 13 fits were accepted, ΔE 2.8–8.9, lightness within 2 L*. Of the 34 files left on the neutral fallback, 21 fail the gates, almost all compacts and kit zooms (TZ/ZS, FZ2500, D-Lux, V-Lux, GF2, GX80 with its 12–32 mm): their camera JPEG is distortion-corrected, the pixel pairs no longer line up and the held-out RMS stays above 0.10 (GX80: 0.138 → 0.107); two S1 II shots just miss it (0.103, 0.107). The other 13 never reach the gates, mostly nearly colourless scenes (grey walls, concrete: under 5% coloured pixels) or dark ones (a night sky, a near-black frame). Panasonic's embedded distortion data (tag `0x0119`) now corrects the render under Enable Profile Corrections (issue #256) but does not yet align the pairs of this fit, and `lightcraft-cli calibrate` doesn't collect RW2 files: the misaligned pairs of distortion-corrected JPEGs would distort a pooled profile.
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony and Nikon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
### Fujifilm RAF
The same guarded fit and relative WB now cover uncompressed, lossless compressed and lossy compressed RAFs, both X-Trans and Bayer. The shared loader serves desktop, CLI, web, export and smart-preview generation. Render cache version 12 refreshes older neutral-colour renders after an updated app is started; existing preview-only catalog entries still need Photo → Reload from Disk.
Verification used the seven supplied X-H2S/X-T4 files and 17 CC0 compressed RAFs (13 camera bodies, including 14/16-bit GFX). Renders at 1200 pixels were compared with each file's embedded JPEG at 200 pixels, in matching orientation, using mean CIE76 ΔE. Without pooled profiles, twenty fits were accepted and every accepted render improved: mean ΔE 16.70 → 4.13. The 16 accepted public files averaged 18.30 → 4.41; the four supplied X-T4 files averaged 10.30 → 3.02. The most difficult accepted public example, GFX100RF, remains at ΔE 11.54, so this is not a parity claim.
The additional supplied folder contains 748 RAFs. Profiles were fitted separately from 201 usable X-H2S and 545 X-T4 photos (722,133 and 1,951,939 colour pairs); one X-T20 file is insufficient for its own profile, and one reference was unusable. The two aggregate profiles are bundled in desktop, CLI and web builds; no private photographs are shipped. Other Fujifilm models use the same guarded per-file fit without a camera-name allowlist or borrowed matrix.
For validation, every fifth usable photo of each model in sorted path order was withheld. Profiles trained on the remaining 160 X-H2S and 436 X-T4 photos were tested on 41 and 109 withheld photos. Mean CIE76 ΔE against the camera JPEG was 9.86 → 1.70 with per-file fitting → 1.62 with the pooled X-H2S profile, and 13.64 → 2.43 → 2.34 for X-T4. The profiles improved 108 of 150 files; the largest regression against per-file fitting was 0.60 ΔE, with no previously accepted fit rejected. These are withheld files from the same limited scene collection, not independent coverage of all subjects or illuminants.
The three original dark X-H2S wrench files have too little colour variation to learn a matrix, but now use the known profile to fit tone: ΔE 8.74 → 2.25, all accepted. The four original X-T4 files and two public X-T4 files remain accepted with the final bundled profile; they regress slightly against their own per-file fits (six-file mean 4.72 → 4.94), while remaining much closer than the neutral fallback (13.47). One public X-T20 reference still fails the gates and keeps the fallback.
RAF inputs also work with `lightcraft-cli calibrate`. Tests cover real X-Trans/16-bit Bayer fits, profile use on dull scenes, per-file fitting after profile rejection, monochrome rejection, resolution independence, header/decoded relative WB, old-catalog WB transitions and invalid RAF calibration inputs. A full-size 6240×4160 X-T4 JPEG export also succeeded.
These are camera-JPEG look estimates, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon, Panasonic and Fujifilm model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
## Camera profiles (pooled per model)
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply, and a rejected fit falls back as before. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
## Nikon crop and preview colour metadata
+36
View File
@@ -0,0 +1,36 @@
# LightCraft auf Deutsch
Wähle **Bearbeiten → Sprache → Deutsch** oder **Einstellungen → Allgemein → Sprache**.
Die Auswahl gilt sofort und wird für den nächsten Start gespeichert (`language: "de"` in `ui.json`).
Die deutschen Kataloge decken alle Schlüssel der bisherigen Übersetzungen sowie sämtliche
Befehls- und Reglerbezeichnungen ab: Menüs, Tastenkürzel, Fotoverwaltung, Bearbeitung,
Zuschnitt, Masken, Personen, Import/Export, Einstellungen, eingebaute Vorgaben und Profile,
Verlauf, Versionen, Datumsüberschriften und Statusmeldungen. Auch der Regel-Editor für
Smart-Alben, Platzhalterhilfen und der Dialog bei ungespeicherten Änderungen sind angebunden.
Die eingebetteten Versionshinweise sind ebenfalls übersetzt. Technische Fehler aus tieferen
Schichten bleiben wie bei den bestehenden Sprachen auf Englisch.
Dateinamen, Befehls-IDs, Vorlagenplatzhalter und selbst benannte Alben, Vorgaben oder Metadaten
werden nicht übersetzt. Die vorhandenen Inter-Schriften enthalten die deutschen Zeichen;
für Deutsch ist kein zusätzlicher Font-Download nötig. Beschreibungen für SAM 3 werden
unverändert an das Modell übergeben; die Beispiele verwenden dessen englische Begriffe.
## Wartung und Prüfung
Feste Texte: `crates/ui-egui/locales/de.json`. Meldungen mit Werten:
`crates/ui-egui/locales/de-formats.json`. Die englischen Schlüssel bleiben unverändert;
Platzhalter werden beim Build durch Rust geprüft. Englische Pluralendungen werden dort,
wo sie nicht passen, mit `{:.0}` ausgeblendet; mengenabhängige Meldungen verwenden neutrale
Formulierungen. Das Datum erscheint beispielsweise als `Sonntag, 20.09.2026`.
```sh
cargo test -p lightcraft-ui-egui i18n::tests
LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000
```
Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal,
Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute
und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`;
gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md).
+23
View File
@@ -0,0 +1,23 @@
# LightCraft на русском
Выберите **Правка → Язык → Русский** или **Настройки → Общие → Язык**. Выбор применяется сразу
и сохраняется для следующего запуска (`language: "ru"` в `ui.json`).
Русский каталог охватывает меню, сочетания клавиш, библиотеку, обработку, кадрирование, маски,
импорт и экспорт, настройки, встроенные стили и профили, историю, даты и сообщения о ходе работы.
Технические ошибки из нижних уровней, как и в других языках, остаются на английском; строки, которых
ещё нет в каталоге, показываются по-английски.
Имена файлов, идентификаторы команд, шаблоны и названные вами альбомы, стили и метаданные не
переводятся. Шрифт интерфейса Inter содержит кириллицу, дополнительные шрифты не нужны.
## Сопровождение и проверка
Постоянные тексты: `crates/ui-egui/locales/ru.json`. Сообщения со значениями:
`crates/ui-egui/locales/ru-formats.json`. Английские ключи не меняются; заполнители проверяются
компилятором Rust при сборке. Английские окончания множественного числа скрываются через `{:.0}`.
```sh
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=ru lightcraft-cli snapshot --demo -o russian.png --size 1600x1000
```
+15 -3
View File
@@ -2,9 +2,10 @@
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan), Japanese (`ja`) and Brazilian Portuguese (`pt-br`). This file is the reference for **adding or maintaining a language**; the per-language notes
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
[`localization-ja.md`](localization-ja.md) and [`localization-pt-br.md`](localization-pt-br.md).
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md),
[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md).
## Adding a language
@@ -13,7 +14,8 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
English source strings and must stay byte-for-byte identical. The plain catalog may grow at its
own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests
-- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The
formats catalog must carry every message (the build fails otherwise), including the date
formats catalog must carry every message (the build fails otherwise; a message copied in English,
value equal to its key, is an untranslated placeholder until someone translates it), including the date
patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
capture times use in every language but English; weekday names go in the plain catalog.
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
@@ -25,6 +27,8 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
}
```
@@ -85,3 +89,11 @@ The settings file stores the BCP-47 code, never the Rust variant name, so a lang
in code without invalidating anyone's saved preference. `LIGHTCRAFT_LANGUAGE` accepts what a system
locale looks like (`zh_Hans`, `zh-CN`, `en_US`, `ja_JP.UTF-8`) and falls back to the base language
when a region has no dedicated entry.
## Release notes
Help ▸ What's New shows `docs/whats-new.md`, and each of its lines (a heading, or one wrapped line of a
bullet, without the `- ` or `### `) is looked up like any message, so a catalog can translate the notes
line by line (German does). A line that is re-wrapped or edited no longer matches its translation and
shows in English until the catalog is updated; untranslated lines are reported by
`display_label_gaps_are_reported`, never failed.
+7 -5
View File
@@ -64,6 +64,8 @@ Take the first one nobody is working on.
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); Nikon CropArea and untagged-preview ColorSpace metadata are respected; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean ΔE slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed ORF, Nikon
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2
@@ -134,8 +136,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
@@ -286,7 +288,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF and Panasonic RW2 get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW and NEF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
@@ -516,8 +518,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese and Brazilian Portuguese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
+51
View File
@@ -0,0 +1,51 @@
# Fujifilm compressed RAF
LightCraft decodes uncompressed, lossless compressed and lossy compressed RAF sensor data in pure Rust. The shared RAW decoder serves import, desktop previews, editing, CLI rendering, web and export. These files no longer enter the embedded-JPEG-only fallback. After restarting with the updated build, existing imported preview-only photos need **Photo → Reload from Disk** to refresh their status; edits are retained.
Fujifilm now uses a guarded colour/tone estimate from each file’s own embedded JPEG when the reference is usable, plus relative as-shot WB and bundled pooled profiles for X-H2S and X-T4. Output pixels still come from the sensor. A rejected profile fit retries the per-file colour fit; if both fail, the neutral fallback remains. Measured sensor calibration and Lightroom fidelity remain separate gaps. See [camera colour verification](camera-preview-colour.md#fujifilm-raf).
## Format and implementation
`crates/raw/src/vendor/raf.rs` reads the RAF header, raw TIFF IFD, crop, CFA, black level and white balance. The big-endian compression field at byte 108 distinguishes uncompressed (0), lossless compressed (2) and lossy compressed (3); testing only the strip's first two bytes would misclassify an uncompressed 16-bit sample of `0x5349`.
`crates/raw/src/vendor/rafc.rs` handles the compression stream:
- A 16-byte `IS` header carries compression mode, sensor type (Bayer or X-Trans), bit depth, dimensions, padded width, 768-pixel stripe width, stripe count and six-row group count. It must agree with the TIFF dimensions and CFA.
- A big-endian size table, padded to 16 bytes, locates independent entropy streams. Lossy files add a table of one quantizer byte per six-row group for each stripe, each table padded to 16 bytes.
- Each six-row group is collated into three red, six green and three blue vectors. Bayer vectors have 384 samples; X-Trans vectors have 512 slots, including interpolation slots that consume no bits. The last stripe's padding is decoded for predictor state but excluded from the output.
- Colour-vector pairs alternate R/G and G/B. Even columns lead odd columns by seven positions. Two preceding lines of each colour and the preceding line's edge values provide prediction context.
- Signed prediction errors use adaptive Rice coding: a unary prefix followed by a context-dependent number of bits, with a bounded escape for large errors. Three pair phases have separate even/odd statistics. The 81 signed gradient contexts fold into 41 states.
- Lossy quantizers change the error range and reconstruction step. Low-gradient sites retain finer precision (0, 1 or 2), with five folded contexts per precision and statistics that survive main-quantizer changes. The main context statistics reset when its quantizer changes. Their initial sum is `max(2, (range + 32) / 64)`; using 16 as the minimum breaks more heavily quantized files.
Stripes decode in parallel using the existing Rayon dependency. Offsets, sizes, dimensions and entropy reads are checked; truncated data, impossible codes and inconsistent headers return errors. Unknown container compression modes, disagreements between container and stream modes, and compressed X-Trans layouts other than the supported CFA return errors. Header-only probing validates the tables and stripe ranges without decompressing the image.
## Verification
Complete sensor arrays were compared sample by sample with an external decoder used only as a black-box oracle (rawpy 0.27.1). No decoder source, Adobe data, camera matrices or profiles were used. This tool is not a product or test dependency.
| Coverage | Files |
|---|---|
| Supplied X-H2S | DSCF4192 uncompressed; DSCF4193 lossless; DSCF4194 lossy |
| Supplied X-T4 | _DSF2355 / _DSF2356 uncompressed; _DSF2357 lossless; _DSF2358 lossy |
| CC0 X-Trans | X-T2, X-T20, X-T4, X-H2, X-T5, X-M5, X-T50, X-E5 |
| CC0 Bayer | GFX 50S (14-bit), GFX 100 (14/16-bit), GFX100S (16-bit lossless/lossy), GFX100RF (16-bit lossy) |
All 24 files matched the reference sensor arrays exactly. This covers 13 camera bodies and both older and current compression variants. Synthetic tests also cover 12-bit streams; a real compressed 12-bit camera file has not been verified. Support is selected from the file's layout, without a camera-name allowlist. Other bodies using this layout should decode, but this is not a claim that every Fujifilm model has been tested. Older FinePix layouts without the supported raw TIFF IFD remain unsupported.
The 17 CC0 files live in the gitignored corpus. Their published SHA-256 identities are pinned in [raf-corpus.sha256](raf-corpus.sha256); their source licence records were checked as CC0 1.0. `cargo xtask corpus --download` fetches them. `cargo test -p lightcraft-raw corpus_fujifilm_compressed_samples -- --nocapture` verifies each available file's checksum before checking its reference sensor sum and position-weighted sum. The test skips absent corpus files; the full-array comparisons were performed locally. No supplied media or large binary fixtures are committed.
Unconditional procedural tests cover Bayer/X-Trans, 12/14/16 bits, partial final stripes, quantizer changes, residual signs and escapes, malformed headers and truncated/random entropy streams. Regressions also check overflowing uncompressed row strides, unsupported modes and CFA layouts, and container/stream mode mismatches.
Local release decode measurements: about 0.16–0.24 seconds for 24–40 MP X-Trans files, 0.29 seconds for 50 MP Bayer, and 0.5–0.6 seconds for 100 MP Bayer. These measure sensor decoding, not the full develop/export pipeline.
App verification imported all four supplied compressed files, confirmed `previewOnly: null`, applied +2 EV exposure edits and inspected headless UI screenshots. CLI JPEG exports at a 1600-pixel long edge succeeded for all four files (0.58–0.76 seconds each, including process startup).
## Sources
- [Fabian's original prose description of lossless RAF compression](https://capnfabs.net/posts/fuji-raf-compression-algorithm/): independent stripes, colour vectors, iteration order, predictors' neighbourhoods, Rice coding and gradient adaptation. Its linked implementations were not consulted.
- [ExifTool Fujifilm tag-name documentation](https://exiftool.sourceforge.net/TagNames/FujiFilm.html): RAF header and raw-IFD metadata fields.
- [Fujifilm's compression-mode explanation](https://digitalcamera-support-en.fujifilm.com/digitalcameraengpcdetail?aid=000008311&wd=compressed+raw).
- [raw.pixls.us](https://raw.pixls.us/): CC0 camera samples, published checksums and licence metadata.
- [rawpy's documented `raw_image` API](https://letmaik.github.io/rawpy/api/rawpy.RawPy.html): the complete sensor array includes margins, allowing comparisons before demosaic or colour processing.
Exact byte layouts, colour-vector masks, prediction formulas, adaptation parameters and lossy precision selection were established through sample analysis and black-box comparisons.
+19
View File
@@ -0,0 +1,19 @@
# Fujifilm RAF regression files; CC0 1.0, raw.pixls.us.
# Published SHA-256 values retrieved 2026-10-07; see docs/raf-compression.md.
018781e126842596fcf3876b3fb6cd8973dc3936ccc753ee8648618be8889287 corpus/raw/raf-fuji-gfx100-3773.raf
0e7dad9819a748bb3d3be26bbf5770d4968e2d80b9cb41d97036bf7c27f7bfe7 corpus/raw/raf-fuji-gfx100-3775.raf
14bed5ac8191d6f2a7c82984e3bf6470ad4740df329293eac42b8018cc6a668a corpus/raw/raf-fuji-gfx100rf-8091.raf
a7bf8f061b603f7ef5fd29276eb6b2ac57355ca2f589a340117bfff0d03c065c corpus/raw/raf-fuji-gfx100s-4495.raf
1bb4608b61861a3d9e85b0ca6c00689b8ba4620cbe8579374d958c21ed08edf0 corpus/raw/raf-fuji-gfx100s-4503.raf
a7124d13910e0241a6b035154bc1fcdb7be4439371d043b1e1950a0251feb51e corpus/raw/raf-fuji-gfx50s-1435.raf
28f56cc9784164209e92fae209bdcc78267bd692aa2fb964595485f7e93692ab corpus/raw/raf-fuji-xe5-8509.raf
caeadb2666f6c5d9bbe32fbe692c2079b6a7e028f8514a13173f360656d08507 corpus/raw/raf-fuji-xh2-6001.raf
109be38f59963c9b8ff289ebe1f5ec9168c52f609e5b601432bd194520b4605b corpus/raw/raf-fuji-xh2-6002.raf
c76ce546e15727abb4ba201471010c554403176963a1a6b8cc017462606429e5 corpus/raw/raf-fuji-xm5-7748.raf
f756ad5be551a8b3e1b503a8a6f7e8e637fba519c0b0f8c79dd58e94b0287602 corpus/raw/raf-fuji-xt2-865.raf
a23045101b2912e64f154100d646c228c0d16585d21e95e0d1a006e537f7784c corpus/raw/raf-fuji-xt20-compressed.raf
07ea642656074834ffe5c661870a612ffa2e80fc1218a05166ee966bbe5f6fae corpus/raw/raf-fuji-xt4-3914.raf
871836938ffa695f4c41765ddb107c2703ce755f7f3ced7e10f9e04746f1734e corpus/raw/raf-fuji-xt4-3918.raf
b2a067e7fda16338f15c2532caa12b238b2415dc1c75a96a6d24d3445cfd0ae3 corpus/raw/raf-fuji-xt5-6122.raf
cddd7ae0c43f9280876e5fdcbdf8878718972ebd3d034d8affc8cfc6752d33dc corpus/raw/raf-fuji-xt5-6123.raf
3a8a9ac6d92274969aaa1f55ea669191b98bb8f588d00ec8ec17afd8b3963dea corpus/raw/raf-fuji-xt50-7807.raf
+4
View File
@@ -46,6 +46,10 @@
- The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks
or unchecks a range of photos (issue #338).
### Languages
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
Traditional), Japanese and Brazilian Portuguese.
### Reliability
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,
labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys
+35
View File
@@ -393,6 +393,41 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
("raw-panasonic-fz50.raw", "https://raw.pixls.us/getfile.php/2234/nice/Panasonic%20-%20DMC-FZ50%20-%204:3.RAW"),
("raw-panasonic-fz8.raw", "https://raw.pixls.us/getfile.php/2282/nice/Panasonic%20-%20DMC-FZ8%20-%204:3.RAW"),
("rw2-panasonic-fz1000m2-4x3.rw2", "https://raw.pixls.us/getfile.php/4706/nice/Panasonic%20-%20DC-FZ10002%20-%204:3.RW2"),
// Fujifilm predictive compression: older X-Trans, 40 MP X-Trans, 14/16-bit GFX; both compression modes.
(
"raf-fuji-gfx100-3773.raf",
"https://raw.pixls.us/getfile.php/3773/nice/Fujifilm%20-%20GFX%20100%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF",
),
(
"raf-fuji-gfx100-3775.raf",
"https://raw.pixls.us/getfile.php/3775/nice/Fujifilm%20-%20GFX%20100%20-%2014bit%2014bit%20compressed%20%284:3%29.RAF",
),
("raf-fuji-gfx100rf-8091.raf", "https://raw.pixls.us/getfile.php/8091/nice/Fujifilm%20-%20GFX100RF%20-%2016bit%20compressed%20%284:3%29.RAF"),
(
"raf-fuji-gfx100s-4495.raf",
"https://raw.pixls.us/getfile.php/4495/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20lossless%20compressed%20%284:3%29.RAF",
),
(
"raf-fuji-gfx100s-4503.raf",
"https://raw.pixls.us/getfile.php/4503/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF",
),
(
"raf-fuji-gfx50s-1435.raf",
"https://raw.pixls.us/getfile.php/1435/nice/Fujifilm%20-%20GFX%2050S%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
),
("raf-fuji-xe5-8509.raf", "https://raw.pixls.us/getfile.php/8509/nice/Fujifilm%20-%20X-E5%20-%2014bit%20lossy%20compressed%20%283:2%29.RAF"),
("raf-fuji-xh2-6001.raf", "https://raw.pixls.us/getfile.php/6001/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xh2-6002.raf", "https://raw.pixls.us/getfile.php/6002/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xm5-7748.raf", "https://raw.pixls.us/getfile.php/7748/nice/Fujifilm%20-%20X-M5%20-%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt2-865.raf", "https://raw.pixls.us/getfile.php/865/nice/Fujifilm%20-%20X-T2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
(
"raf-fuji-xt4-3914.raf",
"https://raw.pixls.us/getfile.php/3914/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.RAF",
),
("raf-fuji-xt4-3918.raf", "https://raw.pixls.us/getfile.php/3918/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt5-6122.raf", "https://raw.pixls.us/getfile.php/6122/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt5-6123.raf", "https://raw.pixls.us/getfile.php/6123/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt50-7807.raf", "https://raw.pixls.us/getfile.php/7807/nice/Fujifilm%20-%20X-T50%20-%2014bit%20compressed%20%283:2%29.RAF"),
("rw2-panasonic-g9-b.rw2", "https://raw.pixls.us/getfile.php/2348/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2"),
("rw2-panasonic-g9.rw2", "https://raw.pixls.us/getfile.php/2585/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2"),
("rw2-panasonic-gh1.rw2", "https://raw.pixls.us/getfile.php/1323/nice/Panasonic%20-%20DMC-GH1%20-%204:3.RW2"),