Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c05f2de251 | ||
|
|
e87ee8e36d | ||
|
|
efef3a5df1 | ||
|
|
b50fbf1285 | ||
|
|
92bd3aa688 | ||
|
|
6592e62912 | ||
|
|
8532d4ea56 | ||
|
|
2218ca5a4c | ||
|
|
14053b2e40 | ||
|
|
1f2f25356a | ||
|
|
2ed6af155d | ||
|
|
b14cf58630 | ||
|
|
104444edcb |
No files matched your search
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
|
||||
Generated
+1
@@ -2188,6 +2188,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -280,9 +280,18 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast; see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row
|
||||
Lightroom parity tracker (every feature, menu item and shortcut with its status, evidence and the top gaps) is
|
||||
[docs/parity.md](docs/parity.md).
|
||||
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity.md](docs/parity.md).
|
||||
- **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;
|
||||
- **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).
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
@@ -292,17 +301,20 @@ Lightroom parity tracker (every feature, menu item and shortcut with its status,
|
||||
| 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; neutral fallback today) |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed), Fujifilm RAF (Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF; embedded previews for every format incl. CR3 | ✅ · compressed NEF/RAF/ORF, CR3 decode 🚧 |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/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, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
|
||||
| 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 ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255) with CPU fallback | ✅ · WebGPU in the browser 🚧 |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
|
||||
+88
-18
@@ -4,6 +4,68 @@ Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every
|
||||
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
|
||||
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
|
||||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. 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.*
|
||||
|
||||
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
|
||||
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
|
||||
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
|
||||
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | 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 | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. 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; no localisation; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| User | Readiness | What blocks them |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
|
||||
|
||||
## Where we're going
|
||||
|
||||
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. Raises colour quality for every non-DNG raw at once.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, 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
|
||||
checked for plausible colour; fix per-model bugs (#85).
|
||||
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
|
||||
(references stay in the local `plan/`), then tune against the numbers.
|
||||
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
|
||||
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
|
||||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
|
||||
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|
||||
@@ -11,7 +73,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
|
||||
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
|
||||
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed, embedded previews ✅; NEF Huffman ⬜) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
|
||||
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
|
||||
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
|
||||
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
|
||||
@@ -19,24 +81,27 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| 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 packed, PEF, ORF uncompressed ✅; CR3, compressed NEF/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, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
|
||||
| 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 ✅; accessibility, localisation ⬜) |
|
||||
|
||||
## Parity estimate (2026-10-02, evening update)
|
||||
## Parity estimate (feature count updated 2026-10-05; 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):
|
||||
|
||||
| Scope | Weighted completion | Rows | Morning |
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.7%** | 198 | 98.5% |
|
||||
| P1 (important parity) | **94.4%** | 144 | 79.9% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **29.4%** | 158 | 11.7% |
|
||||
| **All in-scope rows** | **75.6%** | 500 | 65.7% |
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
|
||||
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
|
||||
|
||||
@@ -50,7 +115,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 NEF / ORF / RAF, RW2 v4, 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 / RAF, NEF lossy-after-split, RW2 v4, 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 |
|
||||
@@ -86,24 +151,25 @@ The milestone estimates in the table above were made before work started and are
|
||||
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
|
||||
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
|
||||
then our own calibration; long-tail camera/lens coverage grows over time.
|
||||
- **Legal decisions pending:** whether GPL-licensed *prose* format descriptions (e.g. the public CR3 write-up) may be read
|
||||
by a designated engineer to produce an internal spec; freedom-to-operate review for local Laplacian filters, PatchMatch and
|
||||
HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is subjective tuning against our own reference targets.
|
||||
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
|
||||
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
|
||||
Laplacian filters, PatchMatch and HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
|
||||
turns it into measured comparisons against local-only Lightroom references.
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
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), NEF/NRW uncompressed, RAF uncompressed (Bayer and
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
|
||||
X-Trans), RW2 packed 12/14-bit, 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
|
||||
it can't decode yet.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon Huffman NEF** (lossless / lossy). Black-box findings so far: maker note `0x0096` holds per-parity predictor
|
||||
seeds (2048 for 14-bit lossless); ≈ 9 bits per pixel; the category-0 code word is a 6-bit rotation of `011111`. The
|
||||
Huffman tables are not stored in the files, and an automatic table search (beam search over prefix codes scored by
|
||||
smoothness and bit rate, validated on Pentax files whose tables *are* stored) did not converge.
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
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).
|
||||
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
|
||||
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
|
||||
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
|
||||
@@ -119,3 +185,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
- 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-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
|
||||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
@@ -44,6 +44,11 @@ fn photo(c: &mut Catalog, i: u64) -> Photo {
|
||||
p.meta.iso = Some(400);
|
||||
p.as_shot_wb = Some((5200.0, 3.0));
|
||||
p.local = !i.is_multiple_of(10);
|
||||
if p.local {
|
||||
// as a browse catalogues it: import defaults, a baseline fingerprint
|
||||
p.develop = Arc::new(p.import_defaults());
|
||||
p.set_local_baseline();
|
||||
}
|
||||
if !p.local {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = (i % 7) as f64 * 0.1;
|
||||
@@ -153,6 +158,47 @@ fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
println!(" background compaction blocking: {}", stats(blocking));
|
||||
|
||||
// forget the untouched Local records (folders last browsed long ago; every 7th folder
|
||||
// recently; every 50th Local record rated), then compact
|
||||
let folders: std::collections::BTreeSet<String> = cat.photos().filter(|p| p.local).filter_map(|p| folder_of(p)).collect();
|
||||
let mut ops: Vec<Op> = folders
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, f)| Op::SetBrowsed {
|
||||
folder: f.clone(),
|
||||
at: Some(if k % 7 == 0 { "2026-09-30T00:00:00" } else { "2026-06-01T00:00:00" }.into()),
|
||||
})
|
||||
.collect();
|
||||
ops.extend(cat.photos().filter(|p| p.local && p.id.0.is_multiple_of(50)).map(|p| Op::SetRating { id: p.id, rating: 2 }).collect::<Vec<_>>());
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
j.snapshot(&cat)?;
|
||||
let before = j.stats().last_snapshot.bytes;
|
||||
let t = Instant::now();
|
||||
let plan = cat.forget_local_plan("2026-10-05T00:00:00", DEFAULT_FORGET_DAYS, &|_| false);
|
||||
let plan_ms = ms(t);
|
||||
let t = Instant::now();
|
||||
let ops = plan.ops();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
let apply_ms = ms(t);
|
||||
j.snapshot(&cat)?;
|
||||
let after = j.stats().last_snapshot.bytes;
|
||||
println!(
|
||||
" forget Local: {} of {} local records forgotten (kept: {} recent, {} touched, {} in use); plan {plan_ms:.1} ms, apply+append {apply_ms:.1} ms; snapshot {:.1} MB -> {:.1} MB",
|
||||
plan.evict.len(),
|
||||
plan.local,
|
||||
plan.kept_recent,
|
||||
plan.kept_touched,
|
||||
plan.kept_in_use,
|
||||
before as f64 / 1e6,
|
||||
after as f64 / 1e6
|
||||
);
|
||||
|
||||
drop(j);
|
||||
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
|
||||
// peak memory of building + compacting only
|
||||
|
||||
@@ -348,7 +348,15 @@ impl Journal {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut timing = write_snapshot(self.store.as_mut(), self.seq, catalog).map_err(io)?;
|
||||
let mut timing = match write_snapshot(self.store.as_mut(), self.seq, catalog) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// nothing lost (the log is whole); don't retry on every append
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
let t2 = web_time::Instant::now();
|
||||
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
pub mod dates;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
@@ -24,6 +25,7 @@ pub use dates::{DateRun, GroupBy};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
@@ -190,6 +192,12 @@ pub enum Op {
|
||||
label: ColorLabel,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// When a Local folder was last browsed (ISO 8601; `None` = forget the time). Not an undo
|
||||
/// step: it drives forgetting untouched Local records (see [`local`]).
|
||||
SetBrowsed {
|
||||
folder: String,
|
||||
at: Option<String>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
@@ -209,6 +217,9 @@ pub struct Catalog {
|
||||
/// Custom colour label names.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
label_names: BTreeMap<ColorLabel, String>,
|
||||
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
browsed: BTreeMap<String, String>,
|
||||
/// Increments on every applied op.
|
||||
#[serde(skip)]
|
||||
pub revision: u64,
|
||||
@@ -547,6 +558,14 @@ impl Catalog {
|
||||
};
|
||||
Op::SetLabelName { label, name: old }
|
||||
}
|
||||
Op::SetBrowsed { folder, at } => {
|
||||
let folder = crate::query::folder_key(&folder);
|
||||
let old = match at {
|
||||
Some(t) => self.browsed.insert(folder.clone(), t),
|
||||
None => self.browsed.remove(&folder),
|
||||
};
|
||||
Op::SetBrowsed { folder, at: old }
|
||||
}
|
||||
Op::Batch { ops } => {
|
||||
let mut inverses = Vec::with_capacity(ops.len());
|
||||
for op in ops {
|
||||
@@ -625,3 +644,5 @@ mod tests;
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Forgetting untouched Local browse records.
|
||||
//!
|
||||
//! Browsing a folder (Local) catalogues its photos as `local` records so they can be shown,
|
||||
//! rated and edited in place. Most are only ever looked at, and on a large disk they can be most
|
||||
//! of the catalog. A record is **forgotten** (removed from the catalog, never from the disk —
|
||||
//! files and XMP sidecars are not touched) when all of these hold:
|
||||
//!
|
||||
//! - it is a Local record ([`Photo::local`]) of a file, not deleted, not a virtual copy;
|
||||
//! - its folder was last browsed more than `days` ago ([`Catalog::last_browsed`]). A folder with
|
||||
//! no recorded time (a catalog from before this existed) counts as browsed *now*: it is stamped
|
||||
//! with the current time and nothing in it is forgotten this time;
|
||||
//! - it is **untouched** ([`Photo::untouched_local`]): nothing about it changed since it was
|
||||
//! browsed — see below;
|
||||
//! - nothing refers to it: no album holds it or shows it as cover, no stack holds it, no virtual
|
||||
//! copy was made of it; and the caller (the engine) says it isn't in use (visible, selected,
|
||||
//! in the undo history, has a smart preview…).
|
||||
//!
|
||||
//! **Untouched.** A record catalogued by a browse carries a fingerprint of its whole state at
|
||||
//! that moment ([`Photo::local_baseline`]). It is untouched while its state still hashes to that
|
||||
//! fingerprint, i.e. *any* change since — develop settings or history, versions, rating, flag,
|
||||
//! label, any metadata (title, caption, keywords, location, copyright…), capture time, crop,
|
||||
//! rename or relink, culling scores, file content — keeps it. Records without a fingerprint
|
||||
//! (browsed before fingerprints existed) are judged by their data alone and kept unless every
|
||||
//! user-editable field is still empty / default: no rating, flag or label; develop settings equal
|
||||
//! to the import defaults with no history, versions or edit time; no title, caption, alt text,
|
||||
//! description, keywords, creator, copyright, rights, location or GPS; no culling scores.
|
||||
//! When in doubt a record is kept.
|
||||
//!
|
||||
//! Browsing the folder again catalogues its files afresh (new records, new fingerprints).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Catalog, Op, Photo, PhotoId, Source};
|
||||
|
||||
/// Default for how long a Local folder may go unbrowsed before its untouched records are
|
||||
/// forgotten (days).
|
||||
pub const DEFAULT_FORGET_DAYS: u32 = 30;
|
||||
|
||||
/// Records removed per logged batch (keeps log lines a reasonable size).
|
||||
const BATCH: usize = 2000;
|
||||
|
||||
/// 64-bit FNV-1a over everything written to it (stable across builds and platforms, unlike
|
||||
/// `std`'s hasher), so a photo is hashed as it serialises, without a buffer.
|
||||
struct Fnv(u64);
|
||||
|
||||
impl std::io::Write for Fnv {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for b in buf {
|
||||
self.0 ^= u64::from(*b);
|
||||
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a file-backed photo is in (`None` for demo photos).
|
||||
pub fn folder_of(p: &Photo) -> Option<String> {
|
||||
match &p.source {
|
||||
// `/` and `\` are both separators whatever the platform (a catalog can move between them)
|
||||
Source::File { path } => {
|
||||
let key = crate::query::folder_key(path);
|
||||
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() })
|
||||
}
|
||||
Source::Demo { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
/// A hash of everything about the photo except [`Photo::local_baseline`] itself.
|
||||
pub fn fingerprint(&self) -> u64 {
|
||||
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
|
||||
let r = if self.local_baseline.is_some() {
|
||||
let mut p = self.clone();
|
||||
p.local_baseline = None;
|
||||
serde_json::to_writer(&mut h, &p)
|
||||
} else {
|
||||
serde_json::to_writer(&mut h, self)
|
||||
};
|
||||
// (serialising a photo can't fail; if it ever did, no baseline would match: kept)
|
||||
if r.is_err() { 0 } else { h.0 }
|
||||
}
|
||||
|
||||
/// Record the current state as the browse baseline (done when a browse catalogues it).
|
||||
pub fn set_local_baseline(&mut self) {
|
||||
self.local_baseline = None;
|
||||
self.local_baseline = Some(self.fingerprint());
|
||||
}
|
||||
|
||||
/// A Local record nothing was changed on since it was browsed (see the module docs). Says
|
||||
/// nothing about albums, stacks or virtual copies referring to it.
|
||||
pub fn untouched_local(&self) -> bool {
|
||||
if !self.local || self.deleted || self.copy_of.is_some() || self.copy_name.is_some() || folder_of(self).is_none() {
|
||||
return false;
|
||||
}
|
||||
match self.local_baseline {
|
||||
Some(b) => self.fingerprint() == b,
|
||||
None => self.looks_unedited(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every user-editable field is still empty / default (records without a baseline).
|
||||
fn looks_unedited(&self) -> bool {
|
||||
let m = &self.meta;
|
||||
self.rating == 0
|
||||
&& self.flag == crate::Flag::None
|
||||
&& self.label.is_none()
|
||||
&& self.edited.is_none()
|
||||
&& self.history.is_empty()
|
||||
&& self.versions.is_empty()
|
||||
&& *self.develop == self.import_defaults()
|
||||
&& self.analysis.is_none()
|
||||
&& m.title.is_empty()
|
||||
&& m.caption.is_empty()
|
||||
&& m.alt_text.is_empty()
|
||||
&& m.extended_description.is_empty()
|
||||
&& m.keywords.is_empty()
|
||||
&& m.creator.is_empty()
|
||||
&& m.copyright.is_empty()
|
||||
&& m.copyright_status.is_unknown()
|
||||
&& m.usage_terms.is_empty()
|
||||
&& m.copyright_url.is_empty()
|
||||
&& m.location.is_empty()
|
||||
&& m.city.is_empty()
|
||||
&& m.state.is_empty()
|
||||
&& m.country.is_empty()
|
||||
&& m.gps.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// What forgetting Local records would do / did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForgetPlan {
|
||||
/// Local records looked at.
|
||||
pub local: usize,
|
||||
/// Records to forget.
|
||||
pub evict: Vec<PhotoId>,
|
||||
/// Kept: their folder was browsed within the period (or has no time yet).
|
||||
pub kept_recent: usize,
|
||||
/// Kept: changed by the user since they were browsed.
|
||||
pub kept_touched: usize,
|
||||
/// Kept: in an album, a stack, the source of a virtual copy, or in use.
|
||||
pub kept_in_use: usize,
|
||||
/// Folders with Local records but no last-browsed time: stamped now.
|
||||
pub stamp: Vec<String>,
|
||||
/// Folders whose last Local record goes: their time is dropped.
|
||||
pub unstamp: Vec<String>,
|
||||
/// The time stamps are given.
|
||||
#[serde(skip)]
|
||||
pub now: String,
|
||||
}
|
||||
|
||||
impl ForgetPlan {
|
||||
/// The ops that carry the plan out (empty when there is nothing to do).
|
||||
pub fn ops(&self) -> Vec<Op> {
|
||||
let mut ops: Vec<Op> = self.stamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: Some(self.now.clone()) }).collect();
|
||||
ops.extend(self.evict.chunks(BATCH).map(|c| Op::Batch { ops: c.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }));
|
||||
ops.extend(self.unstamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: None }));
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// When `folder` was last browsed (ISO 8601), if known.
|
||||
pub fn last_browsed(&self, folder: &str) -> Option<&str> {
|
||||
self.browsed.get(&crate::query::folder_key(folder)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Folders with a last-browsed time.
|
||||
pub fn browsed_folders(&self) -> &BTreeMap<String, String> {
|
||||
&self.browsed
|
||||
}
|
||||
|
||||
/// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of
|
||||
/// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an
|
||||
/// unreadable `now` forgets nothing. See the module docs.
|
||||
pub fn forget_local_plan(&self, now: &str, days: u32, in_use: &dyn Fn(&Photo) -> bool) -> ForgetPlan {
|
||||
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
|
||||
let now_s = crate::stacks::iso_seconds(now);
|
||||
let max_age = i64::from(days) * 86_400;
|
||||
let mut referenced: HashSet<PhotoId> = HashSet::new();
|
||||
for a in self.albums.values() {
|
||||
referenced.extend(a.photos.iter().copied());
|
||||
referenced.extend(a.cover);
|
||||
}
|
||||
for s in self.stacks.values() {
|
||||
referenced.extend(s.photos.iter().copied());
|
||||
}
|
||||
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
|
||||
// the fingerprints are the costly part: work them out for the candidates in parallel
|
||||
let candidates: Vec<&Photo> = self
|
||||
.photos
|
||||
.values()
|
||||
.filter(|p| p.local && !referenced.contains(&p.id))
|
||||
.filter(|p| folder_of(p).and_then(|f| self.browsed.get(&f)).is_some_and(|at| is_old(at, now_s, days, max_age)))
|
||||
.map(|p| &**p)
|
||||
.collect();
|
||||
let untouched: HashSet<PhotoId> = untouched_of(&candidates);
|
||||
let mut stamp = BTreeSet::new();
|
||||
let mut remaining: BTreeSet<String> = BTreeSet::new();
|
||||
for p in self.photos.values().filter(|p| p.local) {
|
||||
plan.local += 1;
|
||||
let Some(folder) = folder_of(p) else {
|
||||
plan.kept_in_use += 1;
|
||||
continue;
|
||||
};
|
||||
let recent = match (self.browsed.get(&folder), now_s) {
|
||||
(None, _) => {
|
||||
stamp.insert(folder.clone());
|
||||
true
|
||||
}
|
||||
(Some(at), _) => !is_old(at, now_s, days, max_age),
|
||||
};
|
||||
let keep = if recent {
|
||||
plan.kept_recent += 1;
|
||||
true
|
||||
} else if referenced.contains(&p.id) || in_use(p) {
|
||||
plan.kept_in_use += 1;
|
||||
true
|
||||
} else if !untouched.contains(&p.id) {
|
||||
plan.kept_touched += 1;
|
||||
true
|
||||
} else {
|
||||
plan.evict.push(p.id);
|
||||
false
|
||||
};
|
||||
if keep {
|
||||
remaining.insert(folder);
|
||||
}
|
||||
}
|
||||
plan.stamp = stamp.into_iter().collect();
|
||||
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
|
||||
fn is_old(at: &str, now_s: Option<i64>, days: u32, max_age: i64) -> bool {
|
||||
days > 0 && matches!((now_s, crate::stacks::iso_seconds(at)), (Some(n), Some(a)) if n - a >= max_age)
|
||||
}
|
||||
|
||||
/// The untouched ones among `photos` ([`Photo::untouched_local`]), checked on several threads
|
||||
/// where there are many.
|
||||
fn untouched_of(photos: &[&Photo]) -> HashSet<PhotoId> {
|
||||
let check = |chunk: &[&Photo]| chunk.iter().filter(|p| p.untouched_local()).map(|p| p.id).collect::<Vec<_>>();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(8);
|
||||
if threads > 1 && photos.len() >= 256 {
|
||||
let size = photos.len().div_ceil(threads);
|
||||
return std::thread::scope(|sc| {
|
||||
let jobs: Vec<_> = photos.chunks(size).map(|c| sc.spawn(move || check(c))).collect();
|
||||
// a job that panicked (it can't) leaves its photos out: they are kept
|
||||
jobs.into_iter().filter_map(|j| j.join().ok()).flatten().collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
check(photos).into_iter().collect()
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// Every photo the op refers to.
|
||||
pub fn photo_ids(&self, out: &mut impl FnMut(PhotoId)) {
|
||||
match self {
|
||||
Op::AddPhoto { photo } => out(photo.id),
|
||||
Op::RemovePhoto { id }
|
||||
| Op::SetRating { id, .. }
|
||||
| Op::SetFlag { id, .. }
|
||||
| Op::SetLabel { id, .. }
|
||||
| Op::SetDevelop { id, .. }
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetDeleted { id, .. }
|
||||
| Op::SetLocal { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetCaptured { id, .. }
|
||||
| Op::SetAnalysis { id, .. }
|
||||
| Op::SetFile { id, .. }
|
||||
| Op::Relink { id, .. }
|
||||
| Op::SetContent { id, .. } => out(*id),
|
||||
Op::AddAlbum { album } => {
|
||||
album.photos.iter().for_each(|p| out(*p));
|
||||
album.cover.into_iter().for_each(&mut *out);
|
||||
}
|
||||
Op::SetAlbumPhotos { photos, .. } | Op::SetStack { photos, .. } => photos.iter().for_each(|p| out(*p)),
|
||||
Op::SetAlbumCover { cover, .. } => cover.iter().for_each(|p| out(*p)),
|
||||
Op::AddStack { stack } => stack.photos.iter().for_each(|p| out(*p)),
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| o.photo_ids(out)),
|
||||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetBrowsed { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,10 @@ pub struct Photo {
|
||||
/// picture style baked in — so it is treated as a rendered (non-raw) source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
|
||||
/// whether the user changed anything since (see [`crate::local`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_baseline: Option<u64>,
|
||||
}
|
||||
|
||||
/// What assisted culling measured on a photo.
|
||||
@@ -319,6 +323,7 @@ impl Photo {
|
||||
import_look: None,
|
||||
analysis: None,
|
||||
preview_only: None,
|
||||
local_baseline: None,
|
||||
}
|
||||
}
|
||||
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Forgetting untouched Local browse records (see [`crate::local`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const OLD: &str = "2026-08-01T10:00:00";
|
||||
const NOW: &str = "2026-10-05T10:00:00";
|
||||
|
||||
/// A Local record of `/pics/<folder>/IMG_<i>.JPG` as a browse catalogues it (with its baseline).
|
||||
fn local(c: &mut Catalog, folder: &str, i: u32) -> PhotoId {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder}/IMG_{i}.JPG") }, &format!("IMG_{i}.JPG"), "JPEG", 60, 40, OLD);
|
||||
p.meta.camera = "Cam".into();
|
||||
p.meta.creator = "From EXIF".into(); // file metadata is part of the baseline, not a user change
|
||||
p.focal_and_wb_for_test();
|
||||
p.local = true;
|
||||
p.set_local_baseline();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
fn focal_and_wb_for_test(&mut self) {
|
||||
self.meta.focal_mm = Some(35.3);
|
||||
self.as_shot_wb = Some((5123.4, 3.1));
|
||||
}
|
||||
}
|
||||
|
||||
fn browsed(c: &mut Catalog, folder: &str, at: &str) {
|
||||
c.apply(Op::SetBrowsed { folder: format!("/pics/{folder}"), at: Some(at.into()) }).unwrap();
|
||||
}
|
||||
|
||||
fn plan(c: &Catalog, days: u32) -> ForgetPlan {
|
||||
c.forget_local_plan(NOW, days, &|_| false)
|
||||
}
|
||||
|
||||
fn run(c: &mut Catalog, days: u32) -> ForgetPlan {
|
||||
let p = plan(c, days);
|
||||
for op in p.ops() {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untouched_records_of_old_folders_are_forgotten() {
|
||||
let mut c = Catalog::new();
|
||||
let old: Vec<_> = (0..5).map(|i| local(&mut c, "old", i)).collect();
|
||||
let recent: Vec<_> = (0..3).map(|i| local(&mut c, "recent", i)).collect();
|
||||
browsed(&mut c, "old", OLD);
|
||||
browsed(&mut c, "recent", "2026-10-01T09:00:00");
|
||||
// a library photo in the old folder is never a candidate
|
||||
let lib = c.alloc_photo_id();
|
||||
let mut p = Photo::new(lib, Source::File { path: "/pics/old/LIB.JPG".into() }, "LIB.JPG", "JPEG", 6, 4, OLD);
|
||||
p.set_local_baseline();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
|
||||
let dry = plan(&c, 30);
|
||||
assert_eq!((dry.local, dry.evict.len(), dry.kept_recent), (8, 5, 3), "{dry:?}");
|
||||
assert_eq!(dry.evict, old);
|
||||
assert_eq!(c.len(), 9, "a plan changes nothing");
|
||||
let done = run(&mut c, 30);
|
||||
assert_eq!(done.evict.len(), 5);
|
||||
assert!(old.iter().all(|id| c.photo(*id).is_none()));
|
||||
assert!(recent.iter().all(|id| c.photo(*id).is_some()));
|
||||
assert!(c.photo(lib).is_some());
|
||||
// the folder has no Local records left: its time goes
|
||||
assert_eq!(done.unstamp, vec!["/pics/old".to_string()]);
|
||||
assert_eq!(c.last_browsed("/pics/old"), None);
|
||||
assert_eq!(c.last_browsed("/pics/recent/"), Some("2026-10-01T09:00:00"));
|
||||
// 0 days = never
|
||||
browsed(&mut c, "recent", OLD);
|
||||
assert!(plan(&c, 0).evict.is_empty());
|
||||
assert_eq!(plan(&c, 30).evict.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_user_change_keeps_a_record() {
|
||||
type Change = Box<dyn Fn(&mut Catalog, PhotoId)>;
|
||||
let set = |f: fn(&mut Photo)| -> Change {
|
||||
Box::new(move |c: &mut Catalog, id: PhotoId| {
|
||||
let mut p = (**c.photo(id).unwrap()).clone();
|
||||
f(&mut p);
|
||||
c.apply(Op::RemovePhoto { id }).unwrap();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
})
|
||||
};
|
||||
let op = |f: fn(PhotoId) -> Op| -> Change { Box::new(move |c: &mut Catalog, id: PhotoId| drop(c.apply(f(id)).unwrap())) };
|
||||
let edited = || {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = 0.3;
|
||||
Arc::new(d)
|
||||
};
|
||||
let changes: Vec<(&str, Change)> = vec![
|
||||
("rating", op(|id| Op::SetRating { id, rating: 3 })),
|
||||
("flag", op(|id| Op::SetFlag { id, flag: Flag::Reject })),
|
||||
("label", op(|id| Op::SetLabel { id, label: Some(ColorLabel::Red) })),
|
||||
("develop", Box::new(move |c, id| drop(c.apply(Op::SetDevelop { id, settings: edited(), label: "Exposure".into(), edited: None }).unwrap()))),
|
||||
("crop", set(|p| Arc::make_mut(&mut p.develop).crop.flip_h = true)),
|
||||
(
|
||||
"history only",
|
||||
Box::new(|c, id| {
|
||||
let step = HistoryStep { label: "Reset".into(), settings: Arc::new(DevelopSettings::default()) };
|
||||
drop(c.apply(Op::PushHistory { id, step }).unwrap())
|
||||
}),
|
||||
),
|
||||
("versions", set(|p| p.versions.push(Version { name: "v".into(), created: NOW.into(), settings: p.develop.clone(), auto: false }))),
|
||||
("title", set(|p| p.meta.title = "T".into())),
|
||||
("caption", set(|p| p.meta.caption = "C".into())),
|
||||
("keywords", set(|p| p.meta.keywords.push("k".into()))),
|
||||
("location", set(|p| p.meta.city = "Oslo".into())),
|
||||
("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))),
|
||||
("copyright", set(|p| p.meta.copyright = "© me".into())),
|
||||
("creator edit", set(|p| p.meta.creator = "Me".into())),
|
||||
("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),
|
||||
("rename", set(|p| p.file_name = "renamed.JPG".into())),
|
||||
("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })),
|
||||
("deleted", op(|id| Op::SetDeleted { id, deleted: true })),
|
||||
("pick", op(|id| Op::SetFlag { id, flag: Flag::Pick })),
|
||||
(
|
||||
"album",
|
||||
Box::new(|c, id| {
|
||||
let a = c.alloc_album_id();
|
||||
let mut al = Album::new(a, "A");
|
||||
al.photos.push(id);
|
||||
drop(c.apply(Op::AddAlbum { album: al }).unwrap())
|
||||
}),
|
||||
),
|
||||
(
|
||||
"stack",
|
||||
Box::new(|c, id| {
|
||||
let other = local(c, "elsewhere", 99);
|
||||
let s = c.alloc_stack_id();
|
||||
drop(c.apply(Op::AddStack { stack: Stack { id: s, photos: vec![id, other], collapsed: false } }).unwrap())
|
||||
}),
|
||||
),
|
||||
(
|
||||
"virtual copy",
|
||||
Box::new(|c, id| {
|
||||
let mut v = (**c.photo(id).unwrap()).clone();
|
||||
v.id = c.alloc_photo_id();
|
||||
v.copy_of = Some(id);
|
||||
v.copy_name = Some("Copy 1".into());
|
||||
v.local = false;
|
||||
drop(c.apply(Op::AddPhoto { photo: Box::new(v) }).unwrap())
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (what, change) in changes {
|
||||
let mut c = Catalog::new();
|
||||
let id = local(&mut c, "old", 1);
|
||||
let control = local(&mut c, "old", 2);
|
||||
browsed(&mut c, "old", OLD);
|
||||
change(&mut c, id);
|
||||
let p = plan(&c, 30);
|
||||
assert_eq!(p.evict, vec![control], "{what}: {p:?}");
|
||||
assert_eq!(p.kept_touched + p.kept_in_use, 1, "{what}: {p:?}");
|
||||
// the stamp stays: a record in the folder remains
|
||||
assert!(p.unstamp.is_empty(), "{what}");
|
||||
}
|
||||
// a change undone (back to the browse state) is untouched again
|
||||
let mut c = Catalog::new();
|
||||
let id = local(&mut c, "old", 1);
|
||||
browsed(&mut c, "old", OLD);
|
||||
let inv = c.apply(Op::SetRating { id, rating: 2 }).unwrap();
|
||||
assert!(plan(&c, 30).evict.is_empty());
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(plan(&c, 30).evict, vec![id]);
|
||||
}
|
||||
|
||||
/// Catalogs from before last-browsed times (and baselines): nothing is forgotten on the first
|
||||
/// run — the folders are stamped now — and baseline-less records are judged by their data.
|
||||
#[test]
|
||||
fn upgrade_without_times_forgets_nothing_then_counts_from_then() {
|
||||
let mut c = Catalog::new();
|
||||
let a = local(&mut c, "a", 1);
|
||||
// legacy records: no baseline
|
||||
let legacy = |c: &mut Catalog, i: u32, f: fn(&mut Photo)| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/a/L{i}.JPG") }, "L.JPG", "JPEG", 6, 4, OLD);
|
||||
p.meta.camera = "Cam".into();
|
||||
p.local = true;
|
||||
f(&mut p);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let clean = legacy(&mut c, 1, |_| {});
|
||||
let rated = legacy(&mut c, 2, |p| p.rating = 1);
|
||||
let with_creator = legacy(&mut c, 3, |p| p.meta.creator = "maybe the user".into());
|
||||
let p = run(&mut c, 30);
|
||||
assert!(p.evict.is_empty(), "{p:?}");
|
||||
assert_eq!(p.stamp, vec!["/pics/a".to_string()]);
|
||||
assert_eq!(c.last_browsed("/pics/a"), Some(NOW));
|
||||
// 30+ days later
|
||||
let later = c.forget_local_plan("2026-11-06T10:00:00", 30, &|_| false);
|
||||
let mut evict = later.evict.clone();
|
||||
evict.sort();
|
||||
assert_eq!(evict, vec![a, clean]);
|
||||
assert_eq!(later.kept_touched, 2, "{rated:?} {with_creator:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_use_records_are_kept() {
|
||||
let mut c = Catalog::new();
|
||||
let a = local(&mut c, "old", 1);
|
||||
let b = local(&mut c, "old", 2);
|
||||
browsed(&mut c, "old", OLD);
|
||||
let p = c.forget_local_plan(NOW, 30, &|p| p.id == a);
|
||||
assert_eq!((p.evict.clone(), p.kept_in_use), (vec![b], 1));
|
||||
}
|
||||
|
||||
/// The ops are journaled: a crash right after forgetting reloads the same catalog, and a
|
||||
/// fingerprint survives the snapshot round trip (a reloaded record is still untouched).
|
||||
#[test]
|
||||
fn forgetting_is_journaled_and_replays() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..10 {
|
||||
local(&mut c, if i % 2 == 0 { "old" } else { "new" }, i);
|
||||
}
|
||||
// log what was built so far: the ops are the records as added
|
||||
for p in c.photos() {
|
||||
ops.push(Op::AddPhoto { photo: Box::new((**p).clone()) });
|
||||
}
|
||||
ops.push(Op::SetBrowsed { folder: "/pics/old".into(), at: Some(OLD.into()) });
|
||||
ops.push(Op::SetBrowsed { folder: "/pics/new".into(), at: Some(NOW.into()) });
|
||||
browsed(&mut c, "old", OLD);
|
||||
browsed(&mut c, "new", NOW);
|
||||
j.append(&ops).unwrap();
|
||||
j.snapshot(&c).unwrap();
|
||||
drop(j);
|
||||
// reload from the snapshot: baselines still match
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let p = c.forget_local_plan(NOW, 30, &|_| false);
|
||||
assert_eq!(p.evict.len(), 5, "{p:?}");
|
||||
let ops = p.ops();
|
||||
for op in &ops {
|
||||
c.apply(op.clone()).unwrap();
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
let expect = c.to_snapshot();
|
||||
drop(j); // crash: replay the log
|
||||
let (_, c2, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!(r.failed, 0);
|
||||
assert_eq!(c2.to_snapshot(), expect);
|
||||
assert_eq!(c2.len(), 5);
|
||||
// nothing left to forget
|
||||
assert!(c2.forget_local_plan(NOW, 30, &|_| false).evict.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_photo_ids_cover_nested_ops() {
|
||||
let op = Op::Batch {
|
||||
ops: vec![
|
||||
Op::SetRating { id: PhotoId(1), rating: 1 },
|
||||
Op::SetAlbumPhotos { id: AlbumId(1), photos: vec![PhotoId(2), PhotoId(3)] },
|
||||
Op::Batch { ops: vec![Op::RemovePhoto { id: PhotoId(4) }] },
|
||||
],
|
||||
};
|
||||
let mut ids = Vec::new();
|
||||
op.photo_ids(&mut |id| ids.push(id.0));
|
||||
assert_eq!(ids, vec![1, 2, 3, 4]);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ lightcraft-scenes = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-gpu = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
//! The folder's photos are probed in place as `local` photos — only folder views list them —
|
||||
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
|
||||
//! makes them ordinary library photos.
|
||||
//!
|
||||
//! Each browse records when the folders it listed were browsed. Untouched records of folders
|
||||
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
|
||||
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
|
||||
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -35,12 +40,113 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
let shown = s.visible().len();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
|
||||
}
|
||||
|
||||
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
|
||||
/// growing the log; far finer than the days the eviction counts in).
|
||||
const RESTAMP_SECS: i64 = 3600;
|
||||
|
||||
/// Record that `root` and the folders of `files` were browsed now (journaled, not an undo step).
|
||||
fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
|
||||
let now = (s.clock)();
|
||||
let now_s = lightcraft_catalog::stacks::iso_seconds(&now);
|
||||
let mut folders: std::collections::BTreeSet<String> =
|
||||
files.iter().filter_map(|f| Path::new(f).parent().map(|d| d.to_string_lossy().trim_end_matches(['/', '\\']).to_string())).collect();
|
||||
folders.insert(root.to_string());
|
||||
let ops: Vec<Op> = folders
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
let at = s.catalog.last_browsed(f).and_then(lightcraft_catalog::stacks::iso_seconds);
|
||||
match (at, now_s) {
|
||||
(Some(at), Some(n)) => n - at >= RESTAMP_SECS || n < at,
|
||||
_ => true,
|
||||
}
|
||||
})
|
||||
.map(|folder| Op::SetBrowsed { folder, at: Some(now.clone()) })
|
||||
.collect();
|
||||
if !ops.is_empty() {
|
||||
let op = Op::Batch { ops };
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
|
||||
/// referred to by an undo / redo step.
|
||||
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
|
||||
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
|
||||
keep.extend(s.selection.ids.iter().copied());
|
||||
keep.extend(s.selection.active);
|
||||
keep.extend(s.previous_active);
|
||||
keep.extend(s.interaction.as_ref().map(|i| i.photo));
|
||||
keep.extend(s.before.keys().copied());
|
||||
for e in s.undo.iter().chain(s.redo.iter()) {
|
||||
e.op.photo_ids(&mut |id| {
|
||||
keep.insert(id);
|
||||
});
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Forget untouched Local records whose folder wasn't browsed for `days` (default
|
||||
/// [`Session::forget_local_days`]; 0 = never). With `dry_run`, only report what would go.
|
||||
/// Journaled (durable, replayed after a crash) but not an undo step; nothing on disk changes.
|
||||
pub fn forget_local(&mut self, dry_run: bool, days: Option<u32>) -> lightcraft_catalog::ForgetPlan {
|
||||
let days = days.unwrap_or(self.forget_local_days);
|
||||
let now = (self.clock)();
|
||||
let keep = in_use(self);
|
||||
let smart = self.media.smart_dir.clone();
|
||||
let plan = self
|
||||
.catalog
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
|
||||
if dry_run {
|
||||
return plan;
|
||||
}
|
||||
for op in plan.ops() {
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(_) => self.pending_log.push(op),
|
||||
Err(e) => log::warn!("forget Local records: {e}"),
|
||||
}
|
||||
}
|
||||
if !plan.evict.is_empty() {
|
||||
log::info!("forgot {} untouched Local record(s) of folders not browsed for {days} days", plan.evict.len());
|
||||
}
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dry_run = bool_or(p, "dryRun", false);
|
||||
let days = match p.get("days") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
Some(v.as_u64().ok_or_else(|| bad("library.forgetLocal", "days must be a whole number (0 = never)"))?.min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
};
|
||||
let used = days.unwrap_or(s.forget_local_days);
|
||||
let plan = s.forget_local(dry_run, days);
|
||||
if !dry_run && !plan.evict.is_empty() {
|
||||
s.compact_soon();
|
||||
}
|
||||
Ok(json!({
|
||||
"dryRun": dry_run,
|
||||
"days": used,
|
||||
"local": plan.local,
|
||||
"forgotten": plan.evict.len(),
|
||||
"keptRecent": plan.kept_recent,
|
||||
"keptTouched": plan.kept_touched,
|
||||
"keptInUse": plan.kept_in_use,
|
||||
"foldersStamped": plan.stamp.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The browsed photos among `ids` (default: the selection) become library photos.
|
||||
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
@@ -98,6 +204,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
browse
|
||||
),
|
||||
cmd!(
|
||||
"library.forgetLocal",
|
||||
"Forget Unchanged Local Photos",
|
||||
[],
|
||||
None,
|
||||
"{dryRun?: bool, days?: n (default: the library preference, 0 = never)} — remove Local (browsed, not added) photos nobody changed, of folders not browsed for `days`, from the catalog; files and sidecars stay, browsing the folder again brings them back; not an undo step → {local, forgotten, keptRecent, keptTouched, keptInUse, foldersStamped, days, dryRun}",
|
||||
always,
|
||||
forget_local
|
||||
),
|
||||
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -867,6 +867,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
// changes applied in memory whose write failed (retried by every save)
|
||||
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
|
||||
"unsavedError": s.unsaved().map(|u| u.1),
|
||||
// untouched Local records forgotten when the library opened
|
||||
"forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})),
|
||||
"persistence": j.stats(),
|
||||
"cache": cache,
|
||||
"load": {
|
||||
|
||||
@@ -14,6 +14,7 @@ fn current(s: &Session) -> Value {
|
||||
"xmp": s.xmp,
|
||||
"import": s.import_defaults,
|
||||
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
|
||||
"forgetLocalDays": s.forget_local_days,
|
||||
"persistent": s.library.is_some(),
|
||||
})
|
||||
}
|
||||
@@ -86,6 +87,11 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
changed = true;
|
||||
}
|
||||
s.import_defaults = d;
|
||||
if let Some(v) = p.get("forgetLocalDays") {
|
||||
let days = v.as_u64().ok_or_else(|| bad(ID, "forgetLocalDays must be a whole number of days (0 = never)"))?;
|
||||
s.forget_local_days = days.min(36_500) as u32;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(mb) = p.get("cacheMb") {
|
||||
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
|
||||
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
|
||||
@@ -101,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Preferences",
|
||||
[],
|
||||
None,
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
|
||||
always,
|
||||
prefs
|
||||
)]
|
||||
|
||||
@@ -186,11 +186,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process)", always, |_, p| {
|
||||
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| {
|
||||
if let Some(on) = p.get("enabled").and_then(Value::as_bool) {
|
||||
lightcraft_gpu::set_enabled(on);
|
||||
}
|
||||
Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()}))
|
||||
Ok(json!({
|
||||
"enabled": lightcraft_gpu::enabled(),
|
||||
"available": lightcraft_gpu::available(),
|
||||
"adapter": lightcraft_gpu::adapter_name(),
|
||||
"reason": lightcraft_gpu::unavailable_reason(),
|
||||
"lastFallback": lightcraft_gpu::last_fallback(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| {
|
||||
Ok(serde_json::to_value(s.memory_report()).unwrap_or_default())
|
||||
|
||||
@@ -722,6 +722,10 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
if opts.local {
|
||||
// the state as browsed: a later change keeps the record from being forgotten
|
||||
p.set_local_baseline();
|
||||
}
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
let log0 = s.pending_log.len();
|
||||
|
||||
@@ -52,6 +52,10 @@ pub enum EngineError {
|
||||
BadParams { cmd: String, msg: String },
|
||||
#[error("{0}")]
|
||||
Catalog(#[from] lightcraft_catalog::CatalogError),
|
||||
/// The command's change is applied (in memory, undoable) but its journal records could not
|
||||
/// be written. They stay queued and are written by the next successful save.
|
||||
#[error("saved in memory but not written to disk: {0}; LightCraft will retry")]
|
||||
NotSaved(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
@@ -168,6 +172,9 @@ pub struct Session {
|
||||
pub cache_mb: u32,
|
||||
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
|
||||
pub smart_previews_dir: Option<std::path::PathBuf>,
|
||||
/// Untouched Local records of folders not browsed for this many days are forgotten when the
|
||||
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
|
||||
pub forget_local_days: u32,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -228,6 +235,7 @@ impl Session {
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,9 +274,18 @@ impl Session {
|
||||
self.auto_write_sidecars(&self.pending_log[log_start..]);
|
||||
}
|
||||
if self.depth == 0 && self.library.is_some() {
|
||||
// Make the command durable before reporting success (on failure the ops stay pending,
|
||||
// are retried after the next command, and `library.info` reports the error).
|
||||
let _ = self.persist();
|
||||
// Make the command durable before reporting success. When the command's own records
|
||||
// can't be appended, it fails with `NotSaved`: the change stays applied in memory and
|
||||
// queued, and the next save (any later command, or the frame loop) retries it. Other
|
||||
// persistence trouble (a failed compaction, an older queued change still unwritten by
|
||||
// a command that changed nothing) doesn't fail the command; `library.info` reports it.
|
||||
let produced = self.pending_log.len() > log_start;
|
||||
if let Err(e @ EngineError::NotSaved(_)) = self.persist()
|
||||
&& produced
|
||||
&& r.is_ok()
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -599,6 +616,8 @@ mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
@@ -611,6 +630,8 @@ mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
//! via [`Session::open_library_in`] (the browser build keeps them in OPFS or IndexedDB).
|
||||
//!
|
||||
//! Every top-level [`Session::execute`] persists the ops it produced (fsynced) before returning,
|
||||
//! so a crash loses at most the command in flight. The log is compacted into a snapshot when it
|
||||
//! so a crash loses at most the command in flight. When that write fails the command returns
|
||||
//! [`EngineError::NotSaved`]: its change stays applied in memory and queued, and every later save
|
||||
//! retries the queue ([`Session::unsaved`] reports it meanwhile), so nothing is lost once the
|
||||
//! disk is writable again. The log is compacted into a snapshot when it
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]; written by a worker thread on native, see
|
||||
//! [`lightcraft_catalog::journal`]) and on [`Session::close_library`].
|
||||
|
||||
@@ -52,6 +55,13 @@ pub struct Library {
|
||||
pub report: LoadReport,
|
||||
/// Last persistence error (shown by the UI; ops stay pending and are retried).
|
||||
pub last_error: Option<String>,
|
||||
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
|
||||
/// exist only in memory ([`Session::unsaved`]).
|
||||
pub unsaved_error: Option<String>,
|
||||
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
|
||||
retry_at: Option<web_time::Instant>,
|
||||
/// What forgetting untouched Local records did when the library opened.
|
||||
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
|
||||
presets_written: String,
|
||||
view_written: Vec<u8>,
|
||||
}
|
||||
@@ -133,6 +143,9 @@ struct PrefsFile {
|
||||
cache_mb: u32,
|
||||
/// Folder for smart previews (default: `Smart Previews` in the library).
|
||||
smart_previews_dir: Option<String>,
|
||||
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
|
||||
/// the default, 0 = never).
|
||||
forget_local_days: Option<u32>,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -155,6 +168,9 @@ fn poll_compaction(lib: &mut Library) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the frame loop waits before retrying a failed append (commands retry at once).
|
||||
const RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str) -> Option<T> {
|
||||
store.read(name).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
|
||||
}
|
||||
@@ -222,6 +238,7 @@ impl Session {
|
||||
self.recent_keywords = prefs.recent_keywords;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
|
||||
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
|
||||
if let Some(d) = &self.smart_previews_dir {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
@@ -248,28 +265,70 @@ impl Session {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&library.report)
|
||||
self.library = Some(Library {
|
||||
dir,
|
||||
on_disk,
|
||||
journal,
|
||||
files,
|
||||
report,
|
||||
last_error: None,
|
||||
unsaved_error: None,
|
||||
retry_at: None,
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
view_written,
|
||||
});
|
||||
// forget untouched Local records of folders not browsed for a while (journaled at once)
|
||||
if self.forget_local_days > 0 {
|
||||
let plan = self.forget_local(false, None);
|
||||
if !plan.evict.is_empty() {
|
||||
self.compact_soon();
|
||||
} else if let Err(e) = self.persist() {
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
if let Some(lib) = self.library.as_mut() {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
match &self.library {
|
||||
Some(lib) => Ok(&lib.report),
|
||||
None => Err(EngineError::Other("library closed while opening".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||
/// Called after every top-level command; cheap when nothing changed.
|
||||
/// Called after every top-level command; cheap when nothing changed. Fails (with
|
||||
/// [`EngineError::NotSaved`]) only when the queued ops couldn't be written; they stay queued.
|
||||
pub fn persist(&mut self) -> Result<()> {
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if !self.pending_log.is_empty() {
|
||||
if let Err(e) = lib.journal.append(&self.pending_log) {
|
||||
lib.last_error = Some(e.to_string());
|
||||
log::error!("library: {e}");
|
||||
return Err(e.into());
|
||||
// the ops stay queued (and applied in memory): the next persist retries them
|
||||
let reason = e.to_string();
|
||||
log::error!("library: {} change(s) not written to disk: {reason}", self.pending_log.len());
|
||||
lib.last_error = Some(reason.clone());
|
||||
lib.unsaved_error = Some(reason.clone());
|
||||
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
|
||||
return Err(EngineError::NotSaved(reason));
|
||||
}
|
||||
if lib.unsaved_error.take().is_some() {
|
||||
log::info!("library: queued changes written to disk");
|
||||
}
|
||||
lib.retry_at = None;
|
||||
self.pending_log.clear();
|
||||
lib.last_error = None;
|
||||
}
|
||||
poll_compaction(lib);
|
||||
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
|
||||
// The snapshot is written by a worker thread (natively); appends go on meanwhile.
|
||||
if lib.journal.wants_snapshot() && self.interaction.is_none() {
|
||||
lib.journal.snapshot_in_background(&self.catalog)?;
|
||||
// A failed compaction loses nothing (the log is kept whole and compacted later): it is
|
||||
// reported (`library.info` → `lastError`), not returned.
|
||||
if lib.journal.wants_snapshot()
|
||||
&& self.interaction.is_none()
|
||||
&& let Err(e) = lib.journal.snapshot_in_background(&self.catalog)
|
||||
{
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
@@ -286,13 +345,22 @@ impl Session {
|
||||
/// Persist if commands left ops pending (cheap; frontends call it once per frame for state
|
||||
/// changed outside [`Session::execute`]).
|
||||
pub fn persist_if_dirty(&mut self) {
|
||||
if self.library.is_some() && !self.pending_log.is_empty() {
|
||||
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
|
||||
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
|
||||
let _ = self.persist();
|
||||
} else if let Some(lib) = self.library.as_mut() {
|
||||
poll_compaction(lib);
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes applied in memory but not yet written to disk because saving failed: the number of
|
||||
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
|
||||
/// every command and by [`Session::persist_if_dirty`].
|
||||
pub fn unsaved(&self) -> Option<(usize, &str)> {
|
||||
let lib = self.library.as_ref()?;
|
||||
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
|
||||
}
|
||||
|
||||
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||
pub fn close_library(&mut self) -> Result<()> {
|
||||
if self.library.is_none() {
|
||||
@@ -351,6 +419,7 @@ impl Session {
|
||||
import: self.import_defaults.clone(),
|
||||
cache_mb: self.cache_mb,
|
||||
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
|
||||
forget_local_days: Some(self.forget_local_days),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
@@ -373,6 +442,24 @@ impl Session {
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
/// Save, then start compacting in the background (natively; synchronously elsewhere): after
|
||||
/// a change that shrinks the catalog a lot, so the smaller snapshot replaces the old one.
|
||||
/// Errors are logged and reported like other persistence errors (nothing is lost).
|
||||
pub fn compact_soon(&mut self) {
|
||||
if let Err(e) = self.persist() {
|
||||
log::error!("library: {e}");
|
||||
return;
|
||||
}
|
||||
if self.interaction.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(lib) = self.library.as_mut() else { return };
|
||||
if let Err(e) = lib.journal.snapshot_in_background(&self.catalog) {
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact the log into a snapshot now.
|
||||
pub fn compact_library(&mut self) -> Result<()> {
|
||||
self.persist()?;
|
||||
|
||||
@@ -174,3 +174,36 @@ fn prepared_exports_run_on_another_thread() {
|
||||
assert_eq!(files[0]["width"], 48);
|
||||
assert_eq!(seen.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an
|
||||
/// error) gave an all-black export. The export must come from the CPU instead — the same
|
||||
/// image — with the reason recorded.
|
||||
#[test]
|
||||
fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 20})).unwrap();
|
||||
let o = ExportOptions::from_json(&json!({"format": "png"}));
|
||||
let mean = |b: &[u8]| {
|
||||
let d = lightcraft_codecs::decode(b, Default::default()).unwrap().image;
|
||||
d.data.iter().map(|p| (p[0] + p[1] + p[2]) as f64).sum::<f64>() / (3 * d.data.len()) as f64
|
||||
};
|
||||
let gpu = lightcraft_gpu::available();
|
||||
let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes);
|
||||
lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork);
|
||||
let faulted = export_photo(&mut s, id, &o, 1).unwrap().bytes;
|
||||
if gpu {
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("incomplete"), "{why}");
|
||||
// the GPU is off for the process now: this export renders on the CPU
|
||||
assert!(!lightcraft_gpu::available());
|
||||
}
|
||||
let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes;
|
||||
lightcraft_gpu::reset_failures();
|
||||
let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data;
|
||||
assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render");
|
||||
let m = mean(&faulted);
|
||||
assert!(m > 0.02, "not black: mean {m}");
|
||||
assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}");
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Forgetting untouched Local browse records: by command and when the library opens; changed,
|
||||
//! recent and visible records stay; a crash replays it; browsing again brings records back.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LibrarySource, Selection, Session};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-forget-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn png(p: &Path, seed: u8) {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
||||
let b =
|
||||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap();
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, b).unwrap();
|
||||
}
|
||||
|
||||
fn open(lib: &Path, clock: &Arc<Mutex<String>>) -> Session {
|
||||
let mut s = Session::new().with_fs();
|
||||
let c = clock.clone();
|
||||
s.clock = Box::new(move || c.lock().unwrap().clone());
|
||||
s.open_library(lib, false).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn set(clock: &Arc<Mutex<String>>, t: &str) {
|
||||
*clock.lock().unwrap() = t.to_string();
|
||||
}
|
||||
|
||||
fn local_paths(s: &Session) -> Vec<String> {
|
||||
let mut v: Vec<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter(|p| p.local)
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Leave the folder view (nothing browsed is visible or selected any more).
|
||||
fn look_elsewhere(s: &mut Session) {
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
s.selection = Selection::default();
|
||||
s.previous_active = None;
|
||||
s.undo.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untouched_records_of_unbrowsed_folders_are_forgotten_and_come_back() {
|
||||
let root = temp_dir("cmd");
|
||||
let (a, b, lib) = (root.join("a"), root.join("b"), root.join("lib"));
|
||||
for (i, n) in ["a1.png", "a2.png", "a3.png"].iter().enumerate() {
|
||||
png(&a.join(n), i as u8);
|
||||
}
|
||||
png(&b.join("b1.png"), 7);
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
// the user rates a2 (a change: kept)
|
||||
let a2 = s.catalog.photos().find(|p| p.file_name == "a2.png").unwrap().id;
|
||||
s.selection = Selection::single(a2);
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
set(&clock, "2026-08-25T10:00:00");
|
||||
s.execute("library.browse", &json!({"path": b.to_string_lossy()})).unwrap();
|
||||
look_elsewhere(&mut s);
|
||||
|
||||
// 40 days after a was browsed, 16 after b
|
||||
set(&clock, "2026-09-10T10:00:00");
|
||||
let dry = s.execute("library.forgetLocal", &json!({"dryRun": true})).unwrap();
|
||||
assert_eq!(
|
||||
(dry["local"].as_u64(), dry["forgotten"].as_u64(), dry["keptTouched"].as_u64(), dry["keptRecent"].as_u64()),
|
||||
(Some(4), Some(2), Some(1), Some(1)),
|
||||
"{dry}"
|
||||
);
|
||||
assert_eq!(local_paths(&s).len(), 4, "a dry run changes nothing");
|
||||
let r = s.execute("library.forgetLocal", &json!({})).unwrap();
|
||||
assert_eq!(r["forgotten"], 2, "{r}");
|
||||
assert_eq!(local_paths(&s), vec!["a2.png", "b1.png"]);
|
||||
// files on disk are untouched
|
||||
assert!(["a1.png", "a2.png", "a3.png"].iter().all(|n| a.join(n).exists()));
|
||||
// not an undo step
|
||||
assert!(s.undo.is_empty());
|
||||
|
||||
// a crash now: the log replays the same catalog
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = open(&lib, &clock);
|
||||
assert_eq!(s.library.as_ref().unwrap().report.failed, 0);
|
||||
assert_eq!(s.catalog.to_snapshot(), expect, "nothing more to forget on open, same state after replay");
|
||||
|
||||
// browsing a again brings the forgotten files back, fresh; the rated one is the same record
|
||||
let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(2)), "{r}");
|
||||
assert_eq!(s.catalog.photo(a2).unwrap().rating, 3);
|
||||
// while a is shown, nothing in it goes, whatever the time
|
||||
set(&clock, "2027-09-10T10:00:00");
|
||||
assert_eq!(s.execute("library.forgetLocal", &json!({"dryRun": true})).unwrap()["forgotten"], 1, "only b1 (not visible)");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_library_forgets_per_the_preference() {
|
||||
let root = temp_dir("open");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
png(&a.join("x.png"), 1);
|
||||
png(&a.join("y.png"), 2);
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
look_elsewhere(&mut s);
|
||||
// 0 = never
|
||||
let r = s.execute("library.preferences", &json!({"forgetLocalDays": 0})).unwrap();
|
||||
assert_eq!(r["forgetLocalDays"], 0);
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
set(&clock, "2027-01-01T10:00:00");
|
||||
let mut s = open(&lib, &clock);
|
||||
assert_eq!(local_paths(&s).len(), 2, "never");
|
||||
assert!(s.library.as_ref().unwrap().forgot_local.is_none());
|
||||
// back to 30 days: the next open forgets them
|
||||
s.execute("library.preferences", &json!({"forgetLocalDays": 30})).unwrap();
|
||||
s.source = LibrarySource::All;
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let s = open(&lib, &clock);
|
||||
assert!(local_paths(&s).is_empty());
|
||||
let info = {
|
||||
let mut s = s;
|
||||
s.execute("library.info", &json!({})).unwrap()
|
||||
};
|
||||
assert_eq!(info["forgotLocal"]["forgotten"], 2, "{info}");
|
||||
assert!(a.join("x.png").exists() && a.join("y.png").exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A file with an XMP sidecar comes in with the sidecar's rating and keywords: that is its
|
||||
/// browse state, not a user change, so the record is forgotten like any other — and the sidecar
|
||||
/// stays byte for byte, so browsing again reads the same values back.
|
||||
#[test]
|
||||
fn sidecar_values_are_not_user_changes_and_sidecars_stay() {
|
||||
let root = temp_dir("xmp");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
png(&a.join("x.png"), 1);
|
||||
let x = a.join("x.png").to_string_lossy().to_string();
|
||||
let mut p = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
lightcraft_catalog::Source::File { path: x.clone() },
|
||||
"x.png",
|
||||
"PNG",
|
||||
16,
|
||||
12,
|
||||
"2026-01-01T00:00:00",
|
||||
);
|
||||
p.rating = 4;
|
||||
p.meta.keywords = vec!["dunes".into()];
|
||||
let side = crate::sidecar::sidecar_path(&x, Default::default());
|
||||
std::fs::write(&side, crate::sidecar::sidecar_packet(&p, &lightcraft_catalog::Catalog::new())).unwrap();
|
||||
let packet = std::fs::read(&side).unwrap();
|
||||
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
let rec = s.catalog.photos().find(|p| p.local).unwrap().clone();
|
||||
assert_eq!((rec.rating, rec.meta.keywords.clone()), (4, vec!["dunes".to_string()]), "read from the sidecar");
|
||||
look_elsewhere(&mut s);
|
||||
set(&clock, "2026-10-01T10:00:00");
|
||||
assert_eq!(s.execute("library.forgetLocal", &json!({})).unwrap()["forgotten"], 1);
|
||||
assert_eq!(std::fs::read(&side).unwrap(), packet, "the sidecar is untouched");
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
let back = s.catalog.photos().find(|p| p.local).unwrap();
|
||||
assert_ne!(back.id, rec.id, "a fresh record");
|
||||
assert_eq!((back.rating, back.meta.keywords.clone()), (4, vec!["dunes".to_string()]));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A catalog written before browse times existed: its Local records are kept on the first open
|
||||
/// (their folders are stamped then), and forgotten only once the period has passed since.
|
||||
#[test]
|
||||
fn upgraded_catalogs_forget_nothing_at_first() {
|
||||
let root = temp_dir("upgrade");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
png(&a.join("x.png"), 1);
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
look_elsewhere(&mut s);
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
// strip the times and baselines, as an older version would have written the snapshot
|
||||
let snap = std::fs::read_to_string(lib.join("catalog.snap")).unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&snap).unwrap();
|
||||
v["catalog"].as_object_mut().unwrap().remove("browsed");
|
||||
for p in v["catalog"]["photos"].as_object_mut().unwrap().values_mut() {
|
||||
p.as_object_mut().unwrap().remove("local_baseline");
|
||||
}
|
||||
std::fs::write(lib.join("catalog.snap"), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
set(&clock, "2027-01-01T10:00:00");
|
||||
let s = open(&lib, &clock);
|
||||
assert_eq!(local_paths(&s).len(), 1, "kept on the first open after the upgrade");
|
||||
assert_eq!(s.catalog.last_browsed(&a.to_string_lossy()), Some("2027-01-01T10:00:00"));
|
||||
drop(s);
|
||||
set(&clock, "2027-02-15T10:00:00");
|
||||
let s = open(&lib, &clock);
|
||||
assert!(local_paths(&s).is_empty(), "the untouched legacy record goes once 30 days passed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! A failed save is an error, not a silent success: a command whose journal append fails returns
|
||||
//! [`EngineError::NotSaved`], its change stays applied and queued, and the next save writes it.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use lightcraft_catalog::{Flag, MemStore, SnapshotPolicy, Store};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::library::LibraryStores;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// A store whose writes fail while `fail` is set (a full or unplugged disk).
|
||||
#[derive(Clone, Default)]
|
||||
struct Flaky {
|
||||
files: MemStore,
|
||||
fail: Arc<AtomicBool>,
|
||||
/// Only snapshots fail (appends work).
|
||||
snapshots_only: bool,
|
||||
}
|
||||
|
||||
impl Flaky {
|
||||
fn failing(&self) -> bool {
|
||||
self.fail.load(Ordering::SeqCst)
|
||||
}
|
||||
fn set_failing(&self, on: bool) {
|
||||
self.fail.store(on, Ordering::SeqCst);
|
||||
}
|
||||
/// The files as a crash at this moment would leave them.
|
||||
fn crash_copy(&self) -> Flaky {
|
||||
let m = MemStore::new();
|
||||
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
|
||||
Flaky { files: m, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Flaky {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.failing() {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.failing() && !self.snapshots_only {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"flaky".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn open(store: &Flaky, seed: bool) -> Session {
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
|
||||
s.open_library_in(stores, seed).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_append_fails_the_command_and_is_retried() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
|
||||
store.set_failing(true);
|
||||
|
||||
let e = s.execute("photo.rate", &json!({"rating": 4})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(
|
||||
msg.starts_with("saved in memory but not written to disk: ") && msg.contains("disk full") && msg.ends_with("LightCraft will retry"),
|
||||
"{msg}"
|
||||
);
|
||||
// applied in memory, undoable, queued
|
||||
assert_eq!(s.catalog.photo(id).unwrap().rating, 4);
|
||||
assert!(!s.undo.is_empty());
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
|
||||
// queries and commands that change nothing are unaffected (they retry the queue, but report
|
||||
// their own result)
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["unsavedOps"], 1, "{info}");
|
||||
assert!(info["unsavedError"].as_str().unwrap().contains("disk full"), "{info}");
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
// a second change fails too; both stay queued, in order
|
||||
let e = s.execute("photo.flag", &json!({"flag": "pick"})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)));
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
// the frame loop backs off instead of retrying every frame
|
||||
s.persist_if_dirty();
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
// a crash now loses only the unsaved changes; the earlier ones are on disk
|
||||
assert_eq!(open(&store.crash_copy(), true).catalog.photo(id).unwrap().rating, 1);
|
||||
|
||||
// the disk comes back: the next save writes the whole queue
|
||||
store.set_failing(false);
|
||||
s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.library.as_ref().unwrap().last_error.is_none());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s); // no close: like a crash
|
||||
let s2 = open(&store, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let p = s2.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
|
||||
}
|
||||
|
||||
/// Undo is a command like any other: a failed write of its op is reported, and retried.
|
||||
#[test]
|
||||
fn failed_append_of_undo_is_reported_and_retried() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
let before = s.catalog.photo(id).unwrap().rating;
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
store.set_failing(true);
|
||||
assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::NotSaved(_))));
|
||||
assert_eq!(s.catalog.photo(id).unwrap().rating, before);
|
||||
store.set_failing(false);
|
||||
s.persist().unwrap();
|
||||
drop(s);
|
||||
assert_eq!(open(&store, true).catalog.photo(id).unwrap().rating, before);
|
||||
}
|
||||
|
||||
/// A failed compaction is not a failed command: the command's own records were appended.
|
||||
#[test]
|
||||
fn failed_snapshot_does_not_fail_the_command() {
|
||||
let store = Flaky { snapshots_only: true, ..Default::default() };
|
||||
let mut s = open(&store, false);
|
||||
s.library.as_mut().unwrap().journal_mut().policy = SnapshotPolicy { max_records: 1, max_bytes: u64::MAX };
|
||||
store.set_failing(true);
|
||||
s.execute("album.create", &json!({"name": "A"})).unwrap();
|
||||
s.execute("album.create", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.library.as_ref().unwrap().last_error.is_some(), "the compaction failure is reported");
|
||||
drop(s);
|
||||
store.set_failing(false);
|
||||
assert_eq!(open(&store, false).catalog.albums().count(), 2);
|
||||
}
|
||||
+222
-24
@@ -275,37 +275,47 @@ fn module_source(m: &Module, consts: &str) -> String {
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// Create a device on the best available adapter (no software fallback), or `None`.
|
||||
pub fn new() -> Option<Gpu> {
|
||||
/// Create a device on the best available adapter (no software fallback), or why there is none.
|
||||
pub fn new() -> Result<Gpu, String> {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = wgpu::Backends::PRIMARY;
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
)
|
||||
.ok()?;
|
||||
.map_err(|e| format!("no GPU adapter ({e})"))?;
|
||||
let info = adapter.get_info();
|
||||
if info.device_type == wgpu::DeviceType::Cpu {
|
||||
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
|
||||
return None;
|
||||
return Err(format!("software adapter ({}) skipped: the CPU pipeline is faster", info.name));
|
||||
}
|
||||
let limits = adapter.limits();
|
||||
let mut limits = adapter.limits();
|
||||
if limits.max_storage_buffers_per_shader_stage < 10 {
|
||||
log::info!("gpu: {} has too few storage buffers per stage", info.name);
|
||||
return None;
|
||||
return Err(format!("{} has too few storage buffers per shader stage ({} < 10)", info.name, limits.max_storage_buffers_per_shader_stage));
|
||||
}
|
||||
if let Some((binding, buffer)) = limits_override() {
|
||||
limits.max_storage_buffer_binding_size = limits.max_storage_buffer_binding_size.min(binding);
|
||||
limits.max_buffer_size = limits.max_buffer_size.min(buffer);
|
||||
log::info!("gpu: LIGHTCRAFT_GPU_LIMITS: storage bindings ≤ {} MiB, buffers ≤ {} MiB", binding >> 20, buffer >> 20);
|
||||
}
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("lightcraft"),
|
||||
required_limits: limits.clone(),
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()?;
|
||||
// Errors outside an error scope (e.g. out of memory during a render): log, and stop using
|
||||
// the GPU (the render in flight is redone on the CPU).
|
||||
.map_err(|e| format!("{}: device creation failed ({e})", info.name))?;
|
||||
// Errors outside a render's error scopes (see `ErrorScopes`): log, and stop using the GPU.
|
||||
device.on_uncaptured_error(std::sync::Arc::new(|e| {
|
||||
log::error!("gpu: {e}");
|
||||
crate::mark_broken();
|
||||
crate::mark_broken(&format!("device error: {e}"));
|
||||
}));
|
||||
// wgpu reports a lost device (driver reset, GPU hang, watchdog) only here — never as an
|
||||
// error — so without this callback a lost device went unnoticed.
|
||||
device.set_device_lost_callback(|reason, msg| {
|
||||
log::error!("gpu: device lost ({reason:?}): {msg}");
|
||||
crate::mark_broken(&format!("device lost ({reason:?}): {msg}"));
|
||||
});
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let consts = constants();
|
||||
let mut kernels = HashMap::new();
|
||||
@@ -334,7 +344,7 @@ impl Gpu {
|
||||
}
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
|
||||
return None;
|
||||
return Err(format!("{}: kernels failed to build ({e})", info.name));
|
||||
}
|
||||
let dummy = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("dummy"),
|
||||
@@ -343,13 +353,20 @@ impl Gpu {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
|
||||
log::info!("gpu: {} ({:?})", info.name, info.backend);
|
||||
Some(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
|
||||
log::info!("gpu: {} ({:?}), buffers ≤ {} MiB", info.name, info.backend, max_buffer >> 20);
|
||||
Ok(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
|
||||
}
|
||||
|
||||
/// A buffer of `len` 32-bit values with undefined contents (a recycled one when possible).
|
||||
///
|
||||
/// A buffer over the device limit fails the render ([`FailKind::Limit`]) instead of raising a
|
||||
/// validation error: the caller gets a placeholder and the render is redone on the CPU.
|
||||
pub fn buffer(&self, len: usize) -> Buf {
|
||||
let size = (len.max(1) * 4) as u64;
|
||||
if size > self.limit() {
|
||||
self.over_limit(size);
|
||||
return Buf { buf: Some(Tracked::new(self.placeholder())), len };
|
||||
}
|
||||
let recycled = {
|
||||
let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// the smallest pooled buffer that holds `size` with at most 25 % to spare: photos of
|
||||
@@ -426,6 +443,10 @@ impl Gpu {
|
||||
if bytes.is_empty() {
|
||||
return self.buffer(1);
|
||||
}
|
||||
if bytes.len() as u64 > self.limit() {
|
||||
self.over_limit(bytes.len() as u64);
|
||||
return Buf { buf: Some(Tracked::new(self.placeholder())), len };
|
||||
}
|
||||
let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytes,
|
||||
@@ -434,9 +455,32 @@ impl Gpu {
|
||||
Buf { buf: Some(Tracked::new(buf)), len }
|
||||
}
|
||||
|
||||
fn over_limit(&self, size: u64) {
|
||||
fail(
|
||||
FailKind::Limit,
|
||||
format!("a {} MiB buffer exceeds the device's {} MiB storage-buffer limit", size.div_ceil(1 << 20), self.limit() >> 20),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stands in for a buffer that could not be created (the render has failed already).
|
||||
fn placeholder(&self) -> wgpu::Buffer {
|
||||
self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("placeholder"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a buffer of `len` 32-bit values fits the device limits.
|
||||
pub fn fits(&self, len: usize) -> bool {
|
||||
(len as u64) * 4 <= self.max_buffer
|
||||
(len as u64) * 4 <= self.limit()
|
||||
}
|
||||
|
||||
/// Largest buffer a render may use (bytes): the device limit, or a lower one set for the
|
||||
/// render on this thread ([`LimitOverride`]).
|
||||
pub fn limit(&self) -> u64 {
|
||||
LIMIT.try_with(|l| l.get()).ok().flatten().map_or(self.max_buffer, |l| l.min(self.max_buffer))
|
||||
}
|
||||
|
||||
pub fn encoder(&self) -> wgpu::CommandEncoder {
|
||||
@@ -445,10 +489,12 @@ impl Gpu {
|
||||
|
||||
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
|
||||
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
|
||||
if failed() {
|
||||
return; // the render is redone on the CPU: don't spend the GPU on it
|
||||
}
|
||||
let Some(k) = self.kernels.get(name) else {
|
||||
// a kernel name typo: fail this render over to the CPU instead of crashing
|
||||
log::error!("gpu: unknown kernel {name}");
|
||||
crate::mark_broken();
|
||||
fail(FailKind::Fatal, format!("unknown kernel {name}"));
|
||||
return;
|
||||
};
|
||||
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
|
||||
@@ -470,8 +516,16 @@ impl Gpu {
|
||||
}
|
||||
|
||||
/// Submit `enc`, then read back the first `len` 32-bit values of `src`.
|
||||
///
|
||||
/// A failed readback (device lost, map error, timeout) fails the render ([`FailKind::Fatal`])
|
||||
/// and returns zeros, which the caller discards; after an earlier failure in the same render
|
||||
/// nothing more is submitted.
|
||||
pub fn finish_and_read<T: bytemuck::Pod>(&self, mut enc: wgpu::CommandEncoder, src: &Buf, len: usize) -> Vec<T> {
|
||||
assert!(len <= src.len, "readback of {len} values from a buffer of {}", src.len);
|
||||
let zeros = || vec![<T as bytemuck::Zeroable>::zeroed(); len];
|
||||
if failed() {
|
||||
return zeros();
|
||||
}
|
||||
let size = (len * 4) as u64;
|
||||
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback"),
|
||||
@@ -486,17 +540,20 @@ impl Gpu {
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
// A failed readback (device lost, out of memory) marks the GPU broken: the caller
|
||||
// (`crate::render`) then discards this render's zeroed result and renders on the CPU.
|
||||
let failed = |why: String| {
|
||||
log::error!("gpu readback failed: {why}");
|
||||
crate::mark_broken();
|
||||
vec![<T as bytemuck::Zeroable>::zeroed(); len]
|
||||
fail(FailKind::Fatal, format!("readback failed: {why}"));
|
||||
zeros()
|
||||
};
|
||||
match rx.recv() {
|
||||
// A lost device makes the wait fail and the map callback never run: checking both keeps
|
||||
// this from blocking forever (or handing back an unwritten buffer as the image).
|
||||
if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) {
|
||||
return failed(format!("{e}"));
|
||||
}
|
||||
// another thread's poll may be the one that runs the callback: allow it a moment
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
|
||||
Ok(Ok(())) => {}
|
||||
other => return failed(format!("{other:?}")),
|
||||
Ok(Err(e)) => return failed(format!("{e}")),
|
||||
Err(e) => return failed(format!("map callback: {e}")),
|
||||
}
|
||||
let data = match slice.get_mapped_range() {
|
||||
Ok(d) => d,
|
||||
@@ -509,6 +566,127 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest wait for a render's GPU work: beyond it the render fails over to the CPU (drivers
|
||||
/// normally reset a hung GPU much sooner).
|
||||
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// `LIGHTCRAFT_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter.
|
||||
/// `webgpu` / `downlevel` = 128 MiB storage bindings and 256 MiB buffers (the WebGPU defaults),
|
||||
/// `<n>` = n MiB storage bindings and 2n MiB buffers. Returns (binding bytes, buffer bytes).
|
||||
fn limits_override() -> Option<(u64, u64)> {
|
||||
parse_limits(&std::env::var("LIGHTCRAFT_GPU_LIMITS").ok()?)
|
||||
}
|
||||
|
||||
fn parse_limits(v: &str) -> Option<(u64, u64)> {
|
||||
let v = v.trim().to_ascii_lowercase();
|
||||
match v.as_str() {
|
||||
"" => None,
|
||||
"webgpu" | "downlevel" | "default" => {
|
||||
let d = wgpu::Limits::default();
|
||||
Some((d.max_storage_buffer_binding_size, d.max_buffer_size))
|
||||
}
|
||||
n => {
|
||||
let mb: u64 = n.trim_end_matches("mib").trim_end_matches("mb").trim().parse().ok()?;
|
||||
let b = mb.max(1) << 20;
|
||||
Some((b, 2 * b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a GPU render failed (it is then redone on the CPU).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FailKind {
|
||||
/// A buffer over the device limits: this render only.
|
||||
Limit,
|
||||
/// The device ran out of memory: this render only (the buffer pool is emptied).
|
||||
OutOfMemory,
|
||||
/// The image came back entirely black: redone on the CPU in case the GPU dropped work.
|
||||
Blank,
|
||||
/// Device error or loss, readback failure, incomplete image: the GPU is not used again.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Failure {
|
||||
pub kind: FailKind,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// A lower buffer limit for the render on this thread (fault injection in tests).
|
||||
static LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Lowers [`Gpu::limit`] on this thread while it lives.
|
||||
pub(crate) struct LimitOverride;
|
||||
|
||||
impl LimitOverride {
|
||||
pub fn new(bytes: u64) -> LimitOverride {
|
||||
LIMIT.with(|l| l.set(Some(bytes)));
|
||||
LimitOverride
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LimitOverride {
|
||||
fn drop(&mut self) {
|
||||
let _ = LIMIT.try_with(|l| l.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The first failure of the render running on this thread.
|
||||
static FAILURE: std::cell::RefCell<Option<Failure>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Record a failure of the render running on this thread (the first one is kept).
|
||||
pub(crate) fn fail(kind: FailKind, reason: String) {
|
||||
let _ = FAILURE.try_with(|f| {
|
||||
let mut f = f.borrow_mut();
|
||||
if f.is_none() {
|
||||
*f = Some(Failure { kind, reason });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Has the render on this thread failed?
|
||||
pub(crate) fn failed() -> bool {
|
||||
FAILURE.try_with(|f| f.borrow().is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The render's failure, clearing it.
|
||||
pub(crate) fn take_failure() -> Option<Failure> {
|
||||
FAILURE.try_with(|f| f.borrow_mut().take()).ok().flatten()
|
||||
}
|
||||
|
||||
/// wgpu error scopes around a render: out-of-memory, validation and internal errors raised by
|
||||
/// this thread's calls are captured (instead of reaching the uncaptured-error handler) and fail
|
||||
/// the render.
|
||||
pub(crate) struct ErrorScopes([wgpu::ErrorScopeGuard; 3]);
|
||||
|
||||
impl ErrorScopes {
|
||||
pub fn push(g: &Gpu) -> ErrorScopes {
|
||||
ErrorScopes([
|
||||
g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory),
|
||||
g.device.push_error_scope(wgpu::ErrorFilter::Validation),
|
||||
g.device.push_error_scope(wgpu::ErrorFilter::Internal),
|
||||
])
|
||||
}
|
||||
|
||||
/// Pop the scopes: the most telling error captured (out of memory first — an allocation
|
||||
/// failure makes later commands invalid too — then internal, then validation).
|
||||
pub fn pop(self) -> Option<Failure> {
|
||||
let [oom, validation, internal] = self.0;
|
||||
// scopes pop in reverse order of their creation
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
let validation = pollster::block_on(validation.pop());
|
||||
let oom = pollster::block_on(oom.pop());
|
||||
if let Some(e) = oom {
|
||||
return Some(Failure { kind: FailKind::OutOfMemory, reason: format!("out of device memory: {e}") });
|
||||
}
|
||||
internal.or(validation).map(|e| Failure { kind: FailKind::Fatal, reason: format!("device error: {e}") })
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -529,3 +707,23 @@ pub fn groups1(n: usize) -> [u32; 3] {
|
||||
let x = g.min(32768);
|
||||
[x as u32, g.div_ceil(x) as u32, 1]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn limits_override_parses() {
|
||||
assert_eq!(super::parse_limits("webgpu"), Some((128 << 20, 256 << 20)));
|
||||
assert_eq!(super::parse_limits(" Downlevel "), Some((128 << 20, 256 << 20)));
|
||||
assert_eq!(super::parse_limits("64"), Some((64 << 20, 128 << 20)));
|
||||
assert_eq!(super::parse_limits("512MiB"), Some((512 << 20, 1024 << 20)));
|
||||
assert_eq!(super::parse_limits(""), None);
|
||||
assert_eq!(super::parse_limits("lots"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatches_stay_within_the_workgroup_limit() {
|
||||
// 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis
|
||||
assert!(super::groups1(100_000_000 * 3).iter().all(|g| *g <= 65535));
|
||||
assert!(super::groups2(16384, 16384, [16, 16]).iter().all(|g| *g <= 65535));
|
||||
}
|
||||
}
|
||||
+141
-15
@@ -7,14 +7,16 @@
|
||||
//! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`.
|
||||
//!
|
||||
//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or
|
||||
//! [`set_enabled`]), or the render does not fit the device — callers then render on the CPU.
|
||||
//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out
|
||||
//! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`]
|
||||
//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`).
|
||||
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
|
||||
//! compiles to the CPU fallback on wasm32.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
@@ -34,12 +36,95 @@ static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
/// Set when a GPU render failed: the process stays on the CPU from then on.
|
||||
static BROKEN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Why the GPU stopped being used (the first fatal failure).
|
||||
static BROKEN_REASON: Mutex<Option<String>> = Mutex::new(None);
|
||||
/// The latest render that fell back to the CPU, and why.
|
||||
static LAST_FALLBACK: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Stop using the GPU for the rest of the process (after a device error).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn mark_broken() {
|
||||
fn mark_broken(reason: &str) {
|
||||
let mut r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if r.is_none() {
|
||||
*r = Some(reason.to_string());
|
||||
}
|
||||
BROKEN.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn record_fallback(reason: String) {
|
||||
*LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason);
|
||||
}
|
||||
|
||||
/// Why renders do not use the GPU, or `None` when they do (or will, once the device is created).
|
||||
/// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …".
|
||||
/// Never blocks.
|
||||
pub fn unavailable_reason() -> Option<String> {
|
||||
if env_disabled() {
|
||||
return Some("disabled by LIGHTCRAFT_GPU=0".into());
|
||||
}
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return Some("disabled by the GPU rendering preference (app.gpu)".into());
|
||||
}
|
||||
if BROKEN.load(Ordering::Relaxed) {
|
||||
let r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| "device error".into());
|
||||
return Some(format!("stopped after a GPU failure: {r}"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
match GPU.get() {
|
||||
Some(Err(e)) => Some(e.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Some("the browser build has no GPU path yet".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest render that fell back from the GPU to the CPU and why (device limit, out of memory,
|
||||
/// device error, incomplete or blank result), if any.
|
||||
pub fn last_fallback() -> Option<String> {
|
||||
LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// A simulated GPU failure for the next [`render`] on this thread (tests of the CPU fallback).
|
||||
#[doc(hidden)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Fault {
|
||||
/// A command the device rejects (validation error).
|
||||
Validation,
|
||||
/// The per-pixel stage's work never runs (what a driver that silently drops or resets a
|
||||
/// submission looks like: the readback holds no image).
|
||||
DropWork,
|
||||
/// A storage-buffer limit of this many bytes for this render.
|
||||
Limit(u64),
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FAULT: std::cell::Cell<Option<Fault>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Make the next [`render`] on this thread fail with `f` (tests).
|
||||
#[doc(hidden)]
|
||||
pub fn inject_fault(f: Fault) {
|
||||
FAULT.with(|c| c.set(Some(f)));
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn take_fault() -> Option<Fault> {
|
||||
FAULT.with(|c| c.take())
|
||||
}
|
||||
|
||||
/// Use the GPU again after a failure (tests that inject faults).
|
||||
#[doc(hidden)]
|
||||
pub fn reset_failures() {
|
||||
BROKEN.store(false, Ordering::Relaxed);
|
||||
*BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the
|
||||
/// whole process regardless.
|
||||
pub fn set_enabled(on: bool) {
|
||||
@@ -57,20 +142,20 @@ fn env_disabled() -> bool {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static GPU: std::sync::OnceLock<Option<ctx::Gpu>> = std::sync::OnceLock::new();
|
||||
static GPU: std::sync::OnceLock<Result<ctx::Gpu, String>> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn device() -> Option<&'static ctx::Gpu> {
|
||||
if env_disabled() {
|
||||
return None;
|
||||
}
|
||||
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref()
|
||||
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok()
|
||||
}
|
||||
|
||||
/// The device if it has been created (never creates it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> {
|
||||
GPU.get().and_then(|g| g.as_ref())
|
||||
GPU.get().and_then(|g| g.as_ref().ok())
|
||||
}
|
||||
|
||||
/// Create the device and compile the kernels on a background thread now (app start), so the
|
||||
@@ -160,7 +245,7 @@ pub fn set_pool_limit(bytes: u64) {
|
||||
/// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle).
|
||||
pub fn trim_pool(keep: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(g) = GPU.get().and_then(|g| g.as_ref()) {
|
||||
if let Some(g) = existing_device() {
|
||||
g.trim(keep);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -191,19 +276,60 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
||||
}
|
||||
let gpu = device()?;
|
||||
let ext = stages.map(|c| c.extension::<GpuStages>());
|
||||
let r = {
|
||||
let fault = take_fault();
|
||||
let _ = ctx::take_failure(); // nothing left over from an earlier render on this thread
|
||||
let (r, scoped) = {
|
||||
let _scope = ctx::RenderScope::new(gpu);
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref())))
|
||||
let errors = ctx::ErrorScopes::push(gpu);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref(), fault)));
|
||||
(r, errors.pop())
|
||||
};
|
||||
match r {
|
||||
// a device error during the render: its result is not trustworthy
|
||||
Ok(_) if BROKEN.load(Ordering::Relaxed) => None,
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
// An error the device raised explains the readback failures that follow it; a buffer
|
||||
// over the limit explains the errors its placeholder raises.
|
||||
let failure = match (ctx::take_failure(), scoped) {
|
||||
(Some(f), _) if f.kind == ctx::FailKind::Limit => Some(f),
|
||||
(f, scoped) => scoped.or(f),
|
||||
};
|
||||
match (r, failure) {
|
||||
(Err(_), _) => {
|
||||
log::error!("gpu: render failed; using the CPU pipeline from now on");
|
||||
BROKEN.store(true, Ordering::Relaxed);
|
||||
mark_broken("a GPU render panicked");
|
||||
record_fallback("a GPU render panicked; the GPU is not used again".into());
|
||||
None
|
||||
}
|
||||
(Ok(r), Some(f)) => {
|
||||
// the view's cached device stages may hold results of the failed passes
|
||||
if let Some(e) = &ext {
|
||||
e.clear();
|
||||
}
|
||||
let what = match &r {
|
||||
Some(r) => format!("{}×{}", r.image.width, r.image.height),
|
||||
None => "GPU render".into(),
|
||||
};
|
||||
let reason = match f.kind {
|
||||
ctx::FailKind::Fatal => {
|
||||
mark_broken(&f.reason);
|
||||
format!("{what}: {}; the GPU is not used again", f.reason)
|
||||
}
|
||||
ctx::FailKind::OutOfMemory => {
|
||||
gpu.trim(0);
|
||||
format!("{what}: {}", f.reason)
|
||||
}
|
||||
ctx::FailKind::Limit | ctx::FailKind::Blank => format!("{what}: {}", f.reason),
|
||||
};
|
||||
log::warn!("gpu: rendering on the CPU instead: {reason}");
|
||||
if render::profiling() {
|
||||
eprintln!(" gpu fallback to the CPU: {reason}");
|
||||
}
|
||||
record_fallback(reason);
|
||||
None
|
||||
}
|
||||
// a device error on another thread or a lost device during the render
|
||||
(Ok(Some(_)), None) if BROKEN.load(Ordering::Relaxed) => {
|
||||
record_fallback(format!("device failure during the render: {}", unavailable_reason().unwrap_or_default()));
|
||||
None
|
||||
}
|
||||
(Ok(r), None) => r,
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -71,6 +71,8 @@ const FIELDS: &[(&str, usize)] = &[
|
||||
("OUT_Y", 3),
|
||||
("OUT_TRC", 1),
|
||||
("OUT_GAMMA", 1),
|
||||
// first row of a band dispatch (the kernel runs over rows Y0.., see `render`)
|
||||
("Y0", 1),
|
||||
];
|
||||
|
||||
/// `(name, index)` of every field (for the WGSL constants).
|
||||
@@ -87,7 +89,7 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> {
|
||||
}
|
||||
|
||||
/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`].
|
||||
fn index(name: &str) -> Option<usize> {
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
let mut i = 0;
|
||||
for (n, len) in FIELDS {
|
||||
if *n == name {
|
||||
|
||||
+98
-24
@@ -12,7 +12,7 @@ use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local};
|
||||
use lightcraft_raster::resample::Filter;
|
||||
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8};
|
||||
|
||||
use crate::ctx::{Buf, Gpu, groups1, groups2};
|
||||
use crate::ctx::{Buf, FailKind, Gpu, fail, groups1, groups2};
|
||||
use crate::params::{Present, finish_block};
|
||||
|
||||
/// Device-resident stages of one view, kept next to the CPU [`lightcraft_pipeline::StageCache`]
|
||||
@@ -128,27 +128,54 @@ impl GpuStages {
|
||||
pub(crate) struct Cx<'a> {
|
||||
pub gpu: &'a Gpu,
|
||||
enc: Option<wgpu::CommandEncoder>,
|
||||
/// Kernel invocations recorded since the last submit.
|
||||
pending: u64,
|
||||
}
|
||||
|
||||
/// Kernel invocations recorded before a render submits them (under one full-size pass over a
|
||||
/// 24 MP export, several passes over a preview): no single submission runs long.
|
||||
const FLUSH_INVOCATIONS: u64 = 16 << 20;
|
||||
|
||||
/// Pixels per dispatch of the per-pixel stage (the heaviest kernel).
|
||||
const BAND_PIXELS: usize = 4 << 20;
|
||||
|
||||
impl<'a> Cx<'a> {
|
||||
pub fn new(gpu: &'a Gpu) -> Cx<'a> {
|
||||
Cx { gpu, enc: Some(gpu.encoder()) }
|
||||
Cx { gpu, enc: Some(gpu.encoder()), pending: 0 }
|
||||
}
|
||||
|
||||
/// Record kernel `name` (see [`Gpu::run`]).
|
||||
pub fn run(&mut self, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
|
||||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||||
self.gpu.run(enc, name, p, bufs, groups);
|
||||
// every kernel runs 256 invocations per workgroup
|
||||
self.pending += groups.iter().map(|g| *g as u64).product::<u64>() * 256;
|
||||
if self.pending >= FLUSH_INVOCATIONS {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit what is recorded and read back `len` values of `b`.
|
||||
pub fn read<T: bytemuck::Pod>(&mut self, b: &Buf, len: usize) -> Vec<T> {
|
||||
self.pending = 0;
|
||||
let enc = self.enc.take().unwrap_or_else(|| self.gpu.encoder());
|
||||
self.gpu.finish_and_read(enc, b, len)
|
||||
}
|
||||
|
||||
/// Submit what is recorded so far without waiting. Renders submit stage by stage and every
|
||||
/// [`FLUSH_INVOCATIONS`]: long submissions are what GPU watchdogs reset (Windows TDR, the
|
||||
/// i915 hang check; issue #78's iGPU spent seconds on an export recorded as one submission),
|
||||
/// and buffers dropped by earlier work become reusable by later work (lower peak memory).
|
||||
pub fn flush(&mut self) {
|
||||
self.pending = 0;
|
||||
if let Some(enc) = self.enc.take() {
|
||||
self.gpu.submit([enc.finish()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit what is recorded and wait for it (profiling only: stage timings).
|
||||
pub fn sync(&mut self) {
|
||||
self.pending = 0;
|
||||
if let Some(enc) = self.enc.take() {
|
||||
self.gpu.submit([enc.finish()]);
|
||||
}
|
||||
@@ -434,12 +461,13 @@ struct Host {
|
||||
log_l: Option<Arc<Plane>>,
|
||||
}
|
||||
|
||||
fn profiling() -> bool {
|
||||
pub(crate) fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
||||
/// Render on `gpu`, reusing `stages` (if given). `None` when the render does not fit the device.
|
||||
/// Render on `gpu`, reusing `stages` (if given). `None` (or a result the caller discards) when the
|
||||
/// render failed: the reason is recorded with [`crate::ctx::fail`].
|
||||
pub fn render(
|
||||
gpu: &Gpu,
|
||||
src: &Arc<Rgb32f>,
|
||||
@@ -447,20 +475,31 @@ pub fn render(
|
||||
s: &DevelopSettings,
|
||||
req: &RenderRequest,
|
||||
stages: Option<&GpuStages>,
|
||||
fault: Option<crate::Fault>,
|
||||
) -> Option<Rendered> {
|
||||
let mut t = profiling().then(std::time::Instant::now);
|
||||
// Under `LIGHTCRAFT_PROFILE` each stage is submitted and waited for, so the timings are real.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>, cx: &mut Cx<'_>| {
|
||||
if let Some(t) = t {
|
||||
// Each stage is submitted on its own; under `LIGHTCRAFT_PROFILE` also waited for, so the
|
||||
// timings are real.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>, cx: &mut Cx<'_>| match t {
|
||||
Some(t) => {
|
||||
cx.sync();
|
||||
eprintln!(" gpu {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
*t = std::time::Instant::now();
|
||||
}
|
||||
None => cx.flush(),
|
||||
};
|
||||
let plan = lightcraft_pipeline::plan(src, info, s, req);
|
||||
let (w, h) = (plan.w, plan.h);
|
||||
let n = w * h;
|
||||
let _limit = match fault {
|
||||
Some(crate::Fault::Limit(bytes)) => Some(crate::ctx::LimitOverride::new(bytes)),
|
||||
_ => None,
|
||||
};
|
||||
if !gpu.fits(n * 3) {
|
||||
fail(
|
||||
FailKind::Limit,
|
||||
format!("{w}×{h} needs {} MiB buffers, over the device's {} MiB storage-buffer limit", (n * 12).div_ceil(1 << 20), gpu.limit() >> 20),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let s = &*plan.settings;
|
||||
@@ -521,23 +560,35 @@ pub fn render(
|
||||
};
|
||||
let (p, aux) = finish_block(&fp, &terms, &present);
|
||||
let aux = gpu.upload(&aux);
|
||||
let out = gpu.buffer(n);
|
||||
cx.run(
|
||||
"main",
|
||||
&p,
|
||||
&[
|
||||
Some(&lin),
|
||||
Some(&prep.log_l),
|
||||
Some(&prep.base),
|
||||
prep.clarity.as_deref(),
|
||||
prep.texture.as_deref(),
|
||||
prep.dark.as_deref(),
|
||||
masks.as_ref(),
|
||||
Some(&aux),
|
||||
Some(&out),
|
||||
],
|
||||
groups2(w, h, [16, 16]),
|
||||
);
|
||||
// cleared, so that work which never ran cannot pass for an image (see the check below)
|
||||
let out = cx.zeroed(n);
|
||||
if fault == Some(crate::Fault::Validation) {
|
||||
// a dispatch the device rejects (too many workgroups)
|
||||
cx.run("box_h", &[0; 5], &[Some(&aux), Some(&out)], [gpu.device.limits().max_compute_workgroups_per_dimension + 1, 1, 1]);
|
||||
}
|
||||
// in bands of rows: each dispatch (and submission) stays short on a slow GPU
|
||||
let rows = (BAND_PIXELS / w.max(1) / 16 * 16).max(16); // whole workgroups: bands never overlap
|
||||
let y0_at = crate::params::index("Y0").unwrap_or(0);
|
||||
let mut p = p;
|
||||
for y0 in (0..h).step_by(rows).filter(|_| fault != Some(crate::Fault::DropWork)) {
|
||||
p[y0_at] = y0 as u32;
|
||||
cx.run(
|
||||
"main",
|
||||
&p,
|
||||
&[
|
||||
Some(&lin),
|
||||
Some(&prep.log_l),
|
||||
Some(&prep.base),
|
||||
prep.clarity.as_deref(),
|
||||
prep.texture.as_deref(),
|
||||
prep.dark.as_deref(),
|
||||
masks.as_ref(),
|
||||
Some(&aux),
|
||||
Some(&out),
|
||||
],
|
||||
groups2(w, rows.min(h - y0), [16, 16]),
|
||||
);
|
||||
}
|
||||
// the alpha a mask overlay shows: copied out before the readback below submits
|
||||
let overlay_mask = req.overlay.mask(s).map(|m| {
|
||||
let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty());
|
||||
@@ -549,8 +600,25 @@ pub fn render(
|
||||
let data: Vec<[u8; 4]> = cx.read(&out, n);
|
||||
let image = Rgba8 { width: w, height: h, data };
|
||||
lap("finish + readback", &mut t, &mut cx);
|
||||
if crate::ctx::failed() {
|
||||
return None;
|
||||
}
|
||||
// The per-pixel kernel writes alpha 255 everywhere and `out` was cleared: a pixel with any
|
||||
// other alpha means work silently did not run (a driver that reset or dropped a submission
|
||||
// without reporting it — issue #78's all-black exports).
|
||||
let unwritten = unwritten(&image.data);
|
||||
if unwritten > 0 {
|
||||
fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)"));
|
||||
return None;
|
||||
}
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("histogram", &mut t, &mut cx);
|
||||
// An entirely black result (upstream work that did not run would give that too): redo it on
|
||||
// the CPU, which costs time only for the rare photo that really is black.
|
||||
if n >= 4096 && [&histogram.r, &histogram.g, &histogram.b].iter().all(|c| c.first() == Some(&histogram.total)) {
|
||||
fail(FailKind::Blank, "the GPU result is entirely black; rendering it on the CPU to be sure".into());
|
||||
return None;
|
||||
}
|
||||
let mut image = image;
|
||||
let overlay_mask = overlay_mask.map(|r| match r {
|
||||
Ok(b) => cx.read_plane(&b, w, h),
|
||||
@@ -565,6 +633,11 @@ pub fn render(
|
||||
Some(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
|
||||
/// Pixels whose alpha is not 255 (cheap: no early exit, so it vectorizes).
|
||||
fn unwritten(data: &[[u8; 4]]) -> usize {
|
||||
data.chunks(4096).filter(|c| c.iter().fold(255u8, |a, p| a & p[3]) != 255).map(|c| c.iter().filter(|p| p[3] != 255).count()).sum()
|
||||
}
|
||||
|
||||
/// The white-balanced, retouched, denoised image.
|
||||
fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, host: &mut Host) -> Buf {
|
||||
let (w, h) = (plan.w, plan.h);
|
||||
@@ -679,6 +752,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
|
||||
let sub = cx.gpu.buffer(m);
|
||||
map(cx, "subsample", m, &[local::AIRLIGHT_STEP as u32], [Some(&d), None, None], &sub);
|
||||
let air = local::airlight_of(cx.read(&sub, m));
|
||||
cx.flush();
|
||||
let b = Arc::new(d);
|
||||
planes.dark = Some((sg.to_bits(), b.clone(), air));
|
||||
(Some(b), air)
|
||||
|
||||
@@ -243,11 +243,11 @@ fn out_encode(v: f32) -> f32 {
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = pu(F_W);
|
||||
let h = pu(F_H);
|
||||
if (gid.x >= w || gid.y >= h) {
|
||||
let x = gid.x;
|
||||
let y = gid.y + pu(F_Y0);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let x = gid.x;
|
||||
let y = gid.y;
|
||||
let i = y * w + x;
|
||||
let n = w * h;
|
||||
let gain = pf(F_GAIN);
|
||||
|
||||
@@ -65,6 +65,32 @@ fn typical(s: &mut DevelopSettings) {
|
||||
s.detail.sharpen_amount = 40.0;
|
||||
}
|
||||
|
||||
/// A render big enough for the per-pixel stage to run in several bands of rows (and the render
|
||||
/// in many submissions), with grain (position-dependent) and masks.
|
||||
#[test]
|
||||
fn banded_full_size_render_matches() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (3000, 2000);
|
||||
let src = scene(3, w, h);
|
||||
let mut s = DevelopSettings::default();
|
||||
typical(&mut s);
|
||||
s.grain.amount = 30.0;
|
||||
s.vignette.amount = -30.0;
|
||||
s.masks = vec![Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.9) },
|
||||
}],
|
||||
adjust: lightcraft_develop::LocalAdjustments { exposure: -0.6, ..Default::default() },
|
||||
..Default::default()
|
||||
}];
|
||||
check("banded 3000×2000", &src, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h));
|
||||
}
|
||||
|
||||
fn cases() -> Vec<(&'static str, Edit)> {
|
||||
vec![
|
||||
("default", |_| {}),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Issue #78: a GPU render that cannot be trusted — over the device's buffer limits, a device
|
||||
//! error, work that silently did not run — must come back as `None` (the caller renders on the
|
||||
//! CPU) with the reason recorded, never as an image of zeros. One test, sequential: the fatal
|
||||
//! faults stop GPU use for the whole process until `reset_failures`.
|
||||
//! Skips (passes with a note) when no GPU adapter exists.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_gpu::Fault;
|
||||
use lightcraft_pipeline::{RenderRequest, SourceInfo, render};
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
|
||||
let sum: u64 = a.data.iter().zip(&b.data).map(|(p, q)| (0..3).map(|c| p[c].abs_diff(q[c]) as u64).sum::<u64>()).sum();
|
||||
sum as f64 / (a.data.len() * 3) as f64
|
||||
}
|
||||
|
||||
fn masked(s: &mut DevelopSettings, k: usize) {
|
||||
for i in 0..k {
|
||||
let c = Point::new(0.2 + 0.2 * i as f64, 0.5);
|
||||
s.masks.push(Mask {
|
||||
id: i as u32 + 1,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: c, rx: 0.2, ry: 0.15, angle: 0.0, feather: 50.0, invert: false },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
s.masks[i].adjust.exposure = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_gpu_renders_fall_back_with_a_reason() {
|
||||
if !lightcraft_gpu::available() {
|
||||
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
|
||||
return;
|
||||
}
|
||||
assert_eq!(lightcraft_gpu::unavailable_reason(), None);
|
||||
let (w, h) = (1200, 800);
|
||||
let src = Arc::new(lightcraft_scenes::demo_library()[0].render(w, h));
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let mut s = DevelopSettings::default();
|
||||
s.light.exposure = 0.3;
|
||||
s.effects.clarity = 20.0;
|
||||
masked(&mut s, 4);
|
||||
let req = RenderRequest::fit(w, h);
|
||||
let cpu = render(&src, &info, &s, &req).image;
|
||||
let ok = |what: &str| {
|
||||
let g = lightcraft_gpu::render(&src, &info, &s, &req, None)
|
||||
.unwrap_or_else(|| panic!("{what}: gpu render ({:?})", lightcraft_gpu::last_fallback()));
|
||||
let d = mean_abs_diff(&cpu, &g.image);
|
||||
assert!(d < 0.5, "{what}: GPU differs from the CPU by {d:.3} LSB");
|
||||
};
|
||||
ok("healthy");
|
||||
|
||||
// the output itself is over the limit: refused up front, the GPU stays in use
|
||||
lightcraft_gpu::inject_fault(Fault::Limit(1 << 20));
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none());
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("limit"), "{why}");
|
||||
assert!(lightcraft_gpu::available(), "a limit is no device failure");
|
||||
|
||||
// the output fits but the mask planes (4 masks: 4 planes in one buffer) don't: caught when the
|
||||
// buffer is created, without a device error, and the GPU stays in use
|
||||
lightcraft_gpu::inject_fault(Fault::Limit((w * h * 3 * 4 + 1024) as u64));
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none());
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("exceeds"), "{why}");
|
||||
assert!(lightcraft_gpu::available(), "{:?}", lightcraft_gpu::unavailable_reason());
|
||||
ok("after the limit faults");
|
||||
|
||||
// a device (validation) error: captured by the render's error scope; the GPU is not used again
|
||||
lightcraft_gpu::inject_fault(Fault::Validation);
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none());
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("device error"), "{why}");
|
||||
assert!(!lightcraft_gpu::available());
|
||||
assert!(lightcraft_gpu::unavailable_reason().is_some_and(|r| r.contains("device error")), "{:?}", lightcraft_gpu::unavailable_reason());
|
||||
lightcraft_gpu::reset_failures();
|
||||
ok("after a device error");
|
||||
|
||||
// the per-pixel work never runs (a driver dropping a submission without an error): before
|
||||
// the fix this came back as a fully black image
|
||||
lightcraft_gpu::inject_fault(Fault::DropWork);
|
||||
assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none(), "an unwritten image must not be returned");
|
||||
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
|
||||
assert!(why.contains("incomplete"), "{why}");
|
||||
assert!(!lightcraft_gpu::available());
|
||||
lightcraft_gpu::reset_failures();
|
||||
ok("after dropped work");
|
||||
|
||||
// a black photo is still rendered (on the CPU, to be sure)
|
||||
let black = Arc::new(lightcraft_raster::Rgb32f::from_fn(256, 256, |_, _| [0.0; 3]));
|
||||
let s0 = DevelopSettings::default();
|
||||
assert!(lightcraft_gpu::render(&black, &info, &s0, &RenderRequest::fit(256, 256), None).is_none());
|
||||
assert!(lightcraft_gpu::last_fallback().unwrap_or_default().contains("black"));
|
||||
assert!(lightcraft_gpu::available());
|
||||
}
|
||||
@@ -11,10 +11,10 @@
|
||||
//! and produces camera → linear Rec.2020 D65 matrices.
|
||||
//!
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Canon CR2, Nikon NEF/NRW (uncompressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! and X-Trans), Panasonic RW2 (packed 12/14-bit), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
|
||||
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon Huffman NEF, Panasonic
|
||||
//! quantised RW2, compressed ORF/RAF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources
|
||||
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon "lossy after split" NEF,
|
||||
//! Panasonic quantised RW2, compressed ORF/RAF, 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)]
|
||||
|
||||
Vendored
+33
-1
@@ -7,6 +7,11 @@
|
||||
//! - The raw data is IFD#3's single strip: a lossless-JPEG frame (usually 2 or 4 components).
|
||||
//! - The decoded sample stream fills vertical slices left to right: `cr2_slice = [n, w, last]` means `n` slices of
|
||||
//! width `w` then one of width `last`; each slice is filled top to bottom, row by row.
|
||||
//! - Colour filter layout: IFD#3 tag `0xc5e0` (`CR2CFAPattern` in the ExifTool EXIF tag-name docs): 1 = RGGB,
|
||||
//! 2 = BGGR, 3 = GBRG, 4 = GRBG, anchored at the top-left of the full decoded sensor (masked borders included).
|
||||
//! It differs by model (e.g. 3 on the 50D/60D/7D/550D/5D Mark II, 1 on the 40D/5D Mark III/6D/5DS R), so it is
|
||||
//! read per file (no model table); files without the tag fall back to RGGB (issue #85). The parity of the
|
||||
//! `SensorInfo` borders does *not* predict it (RGGB files come with both even and odd top borders).
|
||||
//! - `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
|
||||
@@ -21,6 +26,7 @@ use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t};
|
||||
|
||||
const CR2_SLICE: u16 = 0xc640;
|
||||
const SRAW_TYPE: u16 = 0xc6c5;
|
||||
const CR2_CFA_PATTERN: u16 = 0xc5e0;
|
||||
const SENSOR_INFO: u16 = 0x00e0;
|
||||
const COLOR_BALANCE: u16 = 0x4001;
|
||||
|
||||
@@ -28,6 +34,18 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE)))
|
||||
}
|
||||
|
||||
/// Bayer layout from the raw IFD's `CR2CFAPattern` (`0xc5e0`) value; `None` for missing/unknown values.
|
||||
fn cfa_from_tag(v: Option<u64>) -> Option<Cfa> {
|
||||
let name = match v? {
|
||||
1 => "RGGB",
|
||||
2 => "BGGR",
|
||||
3 => "GBRG",
|
||||
4 => "GRBG",
|
||||
_ => return None,
|
||||
};
|
||||
Some(Cfa::bayer_static(name))
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
@@ -117,7 +135,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
}
|
||||
}
|
||||
let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v));
|
||||
let cfa = Cfa::bayer_static("RGGB");
|
||||
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)
|
||||
} else {
|
||||
@@ -185,6 +203,7 @@ pub(crate) mod tests {
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
let mut ifd3 = IfdBuilder::new();
|
||||
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
|
||||
ifd3.set(CR2_CFA_PATTERN, Value::Long(vec![3]));
|
||||
if let Some(s) = slices {
|
||||
ifd3.set(CR2_SLICE, Value::Short(s.to_vec()));
|
||||
}
|
||||
@@ -206,11 +225,24 @@ pub(crate) mod tests {
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), r.info());
|
||||
assert_eq!(r.data, RawData::U16(img));
|
||||
assert_eq!(r.orientation, Orientation::Rotate270);
|
||||
assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG"), "CR2CFAPattern 3");
|
||||
assert_eq!(r.metadata.iso, Some(400));
|
||||
assert!(r.develop(crate::Method::Ahd).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cfa_pattern_tag() {
|
||||
let name = |v| cfa_from_tag(v).map(|c| c.name());
|
||||
assert_eq!(name(Some(1)).as_deref(), Some("RGGB"));
|
||||
assert_eq!(name(Some(2)).as_deref(), Some("BGGR"));
|
||||
assert_eq!(name(Some(3)).as_deref(), Some("GBRG"));
|
||||
assert_eq!(name(Some(4)).as_deref(), Some("GRBG"));
|
||||
assert_eq!(name(Some(0)), None);
|
||||
assert_eq!(name(Some(7)), None);
|
||||
assert_eq!(name(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wb_probe() {
|
||||
let mut v = vec![0u64; 120];
|
||||
|
||||
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
pub mod arw;
|
||||
pub mod cr2;
|
||||
pub mod nef;
|
||||
pub mod nefc;
|
||||
pub mod orf;
|
||||
pub mod pef;
|
||||
pub mod raf;
|
||||
|
||||
Vendored
+53
-28
@@ -1,22 +1,22 @@
|
||||
//! Nikon NEF / NRW — uncompressed variants only.
|
||||
//! Nikon NEF / NRW — uncompressed and Huffman-compressed (lossless, lossy) variants.
|
||||
//!
|
||||
//! Sources: TIFF 6.0 (the raw image is a standard CFA SubIFD), Laurent Clévy's NEF structure notes (prose: IFD
|
||||
//! layout, SubIFDs, maker note header) and the ExifTool Nikon tag-name documentation (`0x000c` WB_RBLevels,
|
||||
//! `0x003d` BlackLevel). Nikon's Huffman-compressed variants (compression 34713: lossless, lossy type 1/2) are
|
||||
//! **not** implemented: the only descriptions we found are derived from GPL code, which our clean-room rules
|
||||
//! forbid. The coding is T.81-style (difference category + additional bits, as in Pentax PEF), but unlike PEF the
|
||||
//! Huffman tables are not stored in the files and our black-box table search has not converged (see ROADMAP.md).
|
||||
//! They are reported as [`RawError::Unsupported`]; their embedded previews still work.
|
||||
//! `0x003d` BlackLevel, `0x0096` NEFLinearizationTable). The Huffman-compressed data (compression 34713) is decoded
|
||||
//! by [`super::nefc`], which documents its clean-room sources; files it can't decode yet ("lossy after split")
|
||||
//! are reported as [`RawError::Unsupported`] and their embedded previews still work.
|
||||
|
||||
use super::white_from_data;
|
||||
use super::{nefc, white_from_data};
|
||||
use crate::tiffraw::{Packing, read_image};
|
||||
use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::ImageInfo;
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{Ifd, Tiff, makernote};
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote};
|
||||
|
||||
const WB_RB_LEVELS: u16 = 0x000c;
|
||||
const BLACK_LEVEL: u16 = 0x003d;
|
||||
const LINEARIZATION_TABLE: u16 = 0x0096;
|
||||
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.all_ifds()
|
||||
@@ -47,30 +47,15 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("NEF without a CFA image IFD".into()))?;
|
||||
let info = raw.image()?;
|
||||
if info.compression == t::compression::NIKON {
|
||||
return Err(RawError::Unsupported("Nikon Huffman-compressed NEF (no clean-room description available)".into()));
|
||||
}
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let bits = info.bits() as u32;
|
||||
let row_samples = w * info.samples_per_pixel as usize;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let rows_in_first = chunks.first().map(|c| c.height as usize).unwrap_or(h).max(1);
|
||||
let bytes_per_row = chunks.first().map(|c| c.len as usize / rows_in_first).unwrap_or(0);
|
||||
let packing = if bytes_per_row >= row_samples * 2 {
|
||||
Packing::Word16
|
||||
} else if bits == 12 && bytes_per_row * 8 >= row_samples * 12 && bytes_per_row * 8 < row_samples * 13 {
|
||||
Packing::Msb
|
||||
} else if info.compression == 1 && bits != 8 && bits != 16 {
|
||||
return Err(RawError::Unsupported(format!("NEF uncompressed packing ({bytes_per_row} bytes per {row_samples}-sample row)")));
|
||||
} else {
|
||||
Packing::Msb
|
||||
};
|
||||
let data = read_image(bytes, &info, tiff.order, packing)?;
|
||||
let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) };
|
||||
|
||||
let make = ifd0.string(t::MAKE).unwrap_or_default();
|
||||
let mn =
|
||||
tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make));
|
||||
let data =
|
||||
if info.compression == t::compression::NIKON { compressed(bytes, &info, mn.as_ref())? } else { uncompressed(bytes, &info, tiff.order)? };
|
||||
let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) };
|
||||
|
||||
let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_LEVEL)).as_deref() {
|
||||
Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => {
|
||||
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() }
|
||||
@@ -114,6 +99,46 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Uncompressed strips: 16-bit words or 12-bit MSB-packed, told apart by the strip size.
|
||||
fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder) -> Result<RawData> {
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let bits = info.bits() as u32;
|
||||
let row_samples = w * info.samples_per_pixel as usize;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let rows_in_first = chunks.first().map(|c| c.height as usize).unwrap_or(h).max(1);
|
||||
let bytes_per_row = chunks.first().map(|c| c.len as usize / rows_in_first).unwrap_or(0);
|
||||
let packing = if bytes_per_row >= row_samples * 2 {
|
||||
Packing::Word16
|
||||
} else if bits == 12 && bytes_per_row * 8 >= row_samples * 12 && bytes_per_row * 8 < row_samples * 13 {
|
||||
Packing::Msb
|
||||
} else if info.compression == 1 && bits != 8 && bits != 16 {
|
||||
return Err(RawError::Unsupported(format!("NEF uncompressed packing ({bytes_per_row} bytes per {row_samples}-sample row)")));
|
||||
} else {
|
||||
Packing::Msb
|
||||
};
|
||||
read_image(bytes, info, order, packing)
|
||||
}
|
||||
|
||||
/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`.
|
||||
fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>) -> Result<RawData> {
|
||||
// (some Z bodies label uncompressed, row-padded data 34713 without a table: not handled here yet)
|
||||
let table = mn
|
||||
.and_then(|m| Some((m.ifd.bytes(LINEARIZATION_TABLE)?, m.order)))
|
||||
.ok_or_else(|| RawError::Unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)".into()))?;
|
||||
if info.samples_per_pixel != 1 {
|
||||
return Err(RawError::Unsupported(format!("Nikon compressed NEF with {} samples per pixel", info.samples_per_pixel)));
|
||||
}
|
||||
let bits = info.bits() as u32;
|
||||
let table = nefc::parse_table(table.0, table.1, bits)?;
|
||||
// one strip in every sample; several would be contiguous parts of the same bit stream
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let (Some(first), Some(last)) = (chunks.first(), chunks.last()) else { return Err(RawError::Corrupt("NEF: no image data".into())) };
|
||||
let end = last.offset.saturating_add(last.len).min(bytes.len() as u64);
|
||||
let src = usize::try_from(first.offset).ok().zip(usize::try_from(end).ok()).and_then(|(a, b)| bytes.get(a..b));
|
||||
let src = src.ok_or_else(|| RawError::Corrupt("NEF: image data outside the file".into()))?;
|
||||
Ok(RawData::U16(nefc::decode(src, info.width as usize, info.height as usize, bits, &table)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -159,7 +184,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_is_unsupported() {
|
||||
fn compressed_without_table_is_unsupported() {
|
||||
let bytes = nef(34713, 14, vec![vec![0; 64]], 8, 8, 8);
|
||||
assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))));
|
||||
}
|
||||
|
||||
Vendored
+481
@@ -0,0 +1,481 @@
|
||||
//! Nikon Huffman-compressed NEF image data (TIFF compression 34713): "lossless compressed" and "lossy compressed"
|
||||
//! (type 1 and type 2) at 12 and 14 bits.
|
||||
//!
|
||||
//! **Clean-room.** No raw-decoder source code (dcraw, LibRaw, rawspeed, rawloader/rawler, darktable, RawTherapee,
|
||||
//! libopenraw, ExifTool's Perl code, …) was read or consulted. What we used:
|
||||
//!
|
||||
//! - Prose format descriptions: Laurent Clévy, "Nikon Electronic File (NEF) format" (<http://lclevy.free.fr/nef/>),
|
||||
//! the maker-note tag table and the "0x96 (linearization table) tag format" table (version bytes, four `u16`
|
||||
//! predictor seeds at offset 2, curve size at 10, curve at 12, split value at 562 for lossy type 2, "lossy type
|
||||
//! 2 reads an incomplete table and interpolation is required"); Bill Claff, "NEF Compression"
|
||||
//! (<https://www.photonstophotos.net/NikonInfo/NEF_Compression.htm>): a lossy encoding curve followed by a
|
||||
//! non-adaptive (fixed-table) Huffman stage, and the number of encoded values per camera (683, 769, 1025, 2753,
|
||||
//! 3073, 4097 …), which confirmed our curve interpolation; the libopenraw format notes
|
||||
//! (<https://libopenraw.freedesktop.org/formats/nef/>, prose only); ExifTool's Nikon tag-name documentation
|
||||
//! (`0x0093` NEFCompression, `0x0096` NEFLinearizationTable); ITU-T T.81 Annex F/H for the "difference category
|
||||
//! + additional bits" coding and canonical (BITS/HUFFVAL) Huffman codes.
|
||||
//! - Our own black-box analysis of CC0 samples from raw.pixls.us. Nikon's Huffman tables are not stored in the
|
||||
//! files and no prose source lists them, so they were recovered from the data: several cameras (D5100, D300,
|
||||
//! D700) were shot with the same scene in compressed and uncompressed modes; a beam search assigned code words
|
||||
//! to difference categories so that the decoded compressed image matched the uncompressed one (likelihood of
|
||||
//! the decoded values given the reference, minus the bits consumed, plus a description-length penalty per new
|
||||
//! code word). Each table was then verified on ~45 files from ~35 bodies (D40 … D850, Df, Z 6, Z 50, 1 J1): with
|
||||
//! the right table the whole strip decodes and ends within the last byte of the data, with every value inside
|
||||
//! the encoded range. The predictor and difference coding are the same as Pentax PEF (see `pef.rs`).
|
||||
//!
|
||||
//! Format, as established:
|
||||
//! - One strip, MSB-first bit stream, no byte stuffing, continuous across rows. Per pixel a canonical Huffman code
|
||||
//! gives the category `n`, followed by `n` additional bits (T.81: a leading 0 means a negative difference).
|
||||
//! - Each pixel is predicted from the same-colour pixel two to the left; the first two pixels of a row from the
|
||||
//! 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.
|
||||
//! - 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):
|
||||
//! - "Lossy after split" files (non-zero split row at `0x0096` offset 562): from that row on a different table
|
||||
//! is used which our analysis has not recovered yet.
|
||||
//! - Other `0x0096` versions, bit depths other than 12/14.
|
||||
//! - Code words that never occurred in any sample (the lossy 12-bit 8-bit code word `11111110`, and code words
|
||||
//! longer than 9 bits in the lossy tables) are rejected as corrupt; they cannot code a difference within the
|
||||
//! lossy value range anyway.
|
||||
|
||||
use super::pef::{Bits, Huffman, diff};
|
||||
use crate::{MAX_SAMPLES, RawError, Result};
|
||||
use lightcraft_tiff::ByteOrder;
|
||||
|
||||
/// A table code word whose symbol was never observed (see the module docs).
|
||||
const UNSEEN: u8 = u8::MAX;
|
||||
|
||||
/// Canonical Huffman table: number of codes of length 1..=16 and the symbols (difference categories) in code order.
|
||||
struct Table {
|
||||
counts: &'static [u8],
|
||||
symbols: &'static [u8],
|
||||
}
|
||||
|
||||
const LOSSLESS_12: Table = Table { counts: &[0, 1, 4, 2, 3, 1, 2], symbols: &[5, 4, 6, 3, 7, 2, 8, 1, 9, 0, 10, 11, 12] };
|
||||
const LOSSLESS_14: Table = Table { counts: &[0, 1, 4, 2, 2, 3, 1, 2], symbols: &[7, 6, 8, 5, 9, 4, 10, 3, 11, 12, 2, 0, 1, 13, 14] };
|
||||
const LOSSY_12: Table = Table { counts: &[0, 1, 5, 1, 1, 1, 1, 1, 1], symbols: &[5, 4, 3, 6, 2, 7, 1, 0, 8, 9, UNSEEN, 10] };
|
||||
const LOSSY_14: Table = Table { counts: &[0, 1, 4, 3, 1, 1, 1, 1, 1], symbols: &[5, 6, 4, 7, 8, 3, 9, 2, 1, 0, 10, 11, 12] };
|
||||
|
||||
impl Table {
|
||||
/// `(left-aligned 12-bit code, length)` per symbol, for [`Huffman::new`].
|
||||
fn codes(&self) -> Vec<(u16, u8)> {
|
||||
let mut out = vec![(0u16, 0u8); 17];
|
||||
let mut code = 0u32;
|
||||
let mut symbols = self.symbols.iter();
|
||||
for (i, &n) in self.counts.iter().enumerate() {
|
||||
let len = i as u32 + 1;
|
||||
for _ in 0..n {
|
||||
if let Some(&s) = symbols.next()
|
||||
&& let Some(slot) = out.get_mut(s as usize)
|
||||
{
|
||||
*slot = ((code << (12 - len)) as u16, len as u8);
|
||||
}
|
||||
code += 1;
|
||||
}
|
||||
code <<= 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How the decoded values map to samples.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Encoding {
|
||||
/// The decoded values are the samples (`0..2^bits`).
|
||||
Lossless,
|
||||
/// The decoded values index this curve.
|
||||
Lossy(Vec<u16>),
|
||||
}
|
||||
|
||||
/// Maker note `0x0096` (NEF linearization table), parsed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct DecodeTable {
|
||||
pub encoding: Encoding,
|
||||
/// Predictor seeds `[row parity][column]`.
|
||||
pub seeds: [[i32; 2]; 2],
|
||||
}
|
||||
|
||||
/// Parse maker note `0x0096` for a `bits`-bit image. `order` is the maker note's byte order.
|
||||
pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result<DecodeTable> {
|
||||
let u16_at = |o: usize| t.get(o..o + 2).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(|b| order.u16(b));
|
||||
let short = || RawError::Corrupt(format!("NEF: linearization table too short ({} bytes)", t.len()));
|
||||
if bits != 12 && bits != 14 {
|
||||
return Err(RawError::Unsupported(format!("Nikon compressed NEF with {bits}-bit samples")));
|
||||
}
|
||||
let (v0, v1) = (t.first().copied().ok_or_else(short)?, t.get(1).copied().ok_or_else(short)?);
|
||||
let mut seeds = [[0i32; 2]; 2];
|
||||
for (i, s) in seeds.iter_mut().flatten().enumerate() {
|
||||
*s = u16_at(2 + 2 * i).ok_or_else(short)? as i32;
|
||||
}
|
||||
let encoding = match (v0, v1) {
|
||||
(0x46, _) => Encoding::Lossless,
|
||||
(0x44, 0x10 | 0x20 | 0x40) => {
|
||||
let n = u16_at(10).ok_or_else(short)? as usize;
|
||||
let points: Vec<u16> = (0..n).map(|i| u16_at(12 + 2 * i)).collect::<Option<_>>().ok_or_else(short)?;
|
||||
if n < 2 {
|
||||
return Err(RawError::Corrupt(format!("NEF: linearization curve of {n} points")));
|
||||
}
|
||||
if v1 == 0x10 {
|
||||
Encoding::Lossy(points)
|
||||
} else {
|
||||
let split = u16_at(562).unwrap_or(0);
|
||||
if split != 0 {
|
||||
return Err(RawError::Unsupported(format!(
|
||||
"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;
|
||||
if n - 1 > range || !range.is_multiple_of(n - 1) {
|
||||
return Err(RawError::Unsupported(format!("NEF: {n}-point lossy curve for {bits}-bit data")));
|
||||
}
|
||||
let step = range / (n - 1);
|
||||
let mut curve = Vec::with_capacity(range + 1);
|
||||
for pair in points.windows(2) {
|
||||
let &[a, b] = pair else { continue };
|
||||
let (a, b) = (a as u32, b as u32);
|
||||
for k in 0..step as u32 {
|
||||
let s = step as u32;
|
||||
curve.push(((a * (s - k) + b * k) / s) as u16);
|
||||
}
|
||||
}
|
||||
curve.extend(points.last().copied());
|
||||
Encoding::Lossy(curve)
|
||||
}
|
||||
}
|
||||
_ => return Err(RawError::Unsupported(format!("Nikon compressed NEF version {v0:#04x} {v1:#04x}"))),
|
||||
};
|
||||
Ok(DecodeTable { encoding, seeds })
|
||||
}
|
||||
|
||||
/// Decode a `w × h` Nikon Huffman-compressed strip `src` with `bits`-bit samples.
|
||||
pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTable) -> Result<Vec<u16>> {
|
||||
if bits != 12 && bits != 14 {
|
||||
return Err(RawError::Unsupported(format!("Nikon compressed NEF with {bits}-bit samples")));
|
||||
}
|
||||
let n = w.checked_mul(h).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("NEF image size"))?;
|
||||
// every code word is at least 2 bits long: a strip that can't hold the image is truncated (also bounds the
|
||||
// allocation by the file size)
|
||||
if n / 4 > src.len() {
|
||||
return Err(RawError::Corrupt(format!("NEF: {} bytes of compressed data for {w}x{h} pixels", src.len())));
|
||||
}
|
||||
let huff = Huffman::new(
|
||||
&match (&table.encoding, bits) {
|
||||
(Encoding::Lossless, 12) => LOSSLESS_12,
|
||||
(Encoding::Lossless, _) => LOSSLESS_14,
|
||||
(Encoding::Lossy(_), 12) => LOSSY_12,
|
||||
(Encoding::Lossy(_), _) => LOSSY_14,
|
||||
}
|
||||
.codes(),
|
||||
)?;
|
||||
let (lut, max): (&[u16], i32) = match &table.encoding {
|
||||
Encoding::Lossless => (&[], (1i32 << bits) - 1),
|
||||
Encoding::Lossy(curve) => (curve, curve.len() as i32 - 1),
|
||||
};
|
||||
let mut out = vec![0u16; n];
|
||||
let mut stream = Bits::new(src);
|
||||
let mut vpred = table.seeds;
|
||||
let mut clipped = 0usize;
|
||||
for (y, row) in out.chunks_exact_mut(w).enumerate() {
|
||||
let seeds = vpred.get_mut(y & 1).ok_or(RawError::Limit("NEF row"))?;
|
||||
let mut hpred = [0i32; 2];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let d = diff(&mut stream, &huff).ok_or_else(|| RawError::Corrupt(format!("NEF: invalid Huffman code at row {y}")))?;
|
||||
let p = if x < 2 {
|
||||
let s = seeds.get_mut(x).ok_or(RawError::Limit("NEF column"))?;
|
||||
*s += d;
|
||||
hpred[x & 1] = *s;
|
||||
*s
|
||||
} else {
|
||||
hpred[x & 1] += d;
|
||||
hpred[x & 1]
|
||||
};
|
||||
let v = p.clamp(0, max);
|
||||
clipped += (v != p) as usize;
|
||||
*o = if lut.is_empty() { v as u16 } else { lut.get(v as usize).copied().unwrap_or(0) };
|
||||
}
|
||||
// the camera pads the strip; reading more than a few bytes past its end means truncated or corrupt data
|
||||
if stream.consumed_bits() > src.len() * 8 + 64 {
|
||||
return Err(RawError::Corrupt(format!("NEF: compressed data ends at row {y} of {h}")));
|
||||
}
|
||||
}
|
||||
// a wrong table / corrupt stream drifts out of range quickly; real files stay inside (a handful of edge pixels)
|
||||
if clipped > n / 1000 + 16 {
|
||||
return Err(RawError::Corrupt(format!("NEF: {clipped} decoded values out of range")));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// MSB-first bit writer for the test encoder.
|
||||
#[derive(Default)]
|
||||
struct Writer {
|
||||
out: Vec<u8>,
|
||||
acc: u64,
|
||||
n: u32,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn put(&mut self, v: u32, len: u32) {
|
||||
for i in (0..len).rev() {
|
||||
self.acc = (self.acc << 1) | ((v >> i) & 1) as u64;
|
||||
self.n += 1;
|
||||
if self.n == 8 {
|
||||
self.out.push(self.acc as u8);
|
||||
self.acc = 0;
|
||||
self.n = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
if self.n > 0 {
|
||||
self.out.push((self.acc << (8 - self.n)) as u8);
|
||||
}
|
||||
self.out.extend_from_slice(&[0; 4]);
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
fn category(d: i32) -> u32 {
|
||||
32 - d.unsigned_abs().leading_zeros()
|
||||
}
|
||||
|
||||
/// Encode `values` (decoded domain: samples or curve indices) the way the camera does.
|
||||
fn encode(values: &[i32], w: usize, table: &Table, seeds: [[i32; 2]; 2]) -> Vec<u8> {
|
||||
let codes = table.codes();
|
||||
let mut wr = Writer::default();
|
||||
let mut vpred = seeds;
|
||||
for (y, row) in values.chunks(w).enumerate() {
|
||||
for (x, &v) in row.iter().enumerate() {
|
||||
let pred = if x < 2 { vpred[y & 1][x] } else { row[x - 2] };
|
||||
if x < 2 {
|
||||
vpred[y & 1][x] = v;
|
||||
}
|
||||
let d = v - pred;
|
||||
let k = category(d);
|
||||
let (code, len) = codes[k as usize];
|
||||
assert!(len > 0, "no code for category {k}");
|
||||
wr.put(code as u32 >> (12 - len), len as u32);
|
||||
let extra = if d < 0 { d + (1 << k) - 1 } else { d };
|
||||
wr.put(extra as u32, k);
|
||||
}
|
||||
}
|
||||
wr.finish()
|
||||
}
|
||||
|
||||
/// Deterministic test image: smooth gradient + texture + hard edges, within `0..=max`.
|
||||
fn image(w: usize, h: usize, max: i32, seed: u32) -> Vec<i32> {
|
||||
let mut s = seed;
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as i32, (i / w) as i32);
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let noise = (s >> 24) as i32 % 9 - 4;
|
||||
let base = (x * 37 + y * 11) % (max / 2) + if (x / 7 + y / 5) % 3 == 0 { max / 3 } else { 0 };
|
||||
(base + noise).clamp(0, max)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lossless(bits: u32) -> DecodeTable {
|
||||
let s = 1 << (bits - 3);
|
||||
DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_are_complete_prefix_codes() {
|
||||
for t in [LOSSLESS_12, LOSSLESS_14, LOSSY_12, LOSSY_14] {
|
||||
assert_eq!(t.counts.iter().map(|&c| c as usize).sum::<usize>(), t.symbols.len());
|
||||
let kraft: f64 = t.counts.iter().enumerate().map(|(i, &c)| c as f64 / (1u64 << (i + 1)) as f64).sum();
|
||||
assert!(kraft <= 1.0);
|
||||
assert!(Huffman::new(&t.codes()).is_ok());
|
||||
}
|
||||
assert_eq!(LOSSLESS_14.codes()[7], (0b00 << 10, 2));
|
||||
assert_eq!(LOSSLESS_14.codes()[0], (0b111110 << 6, 6));
|
||||
assert_eq!(LOSSY_12.codes()[10], (0b111111110 << 3, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_roundtrip_12_and_14() {
|
||||
for (bits, table) in [(12, LOSSLESS_12), (14, LOSSLESS_14)] {
|
||||
let (w, h) = (64, 9);
|
||||
let max = (1 << bits) - 1;
|
||||
let mut img = image(w, h, max, bits);
|
||||
// extreme jumps exercise the longest categories
|
||||
img[5] = 0;
|
||||
img[7] = max;
|
||||
img[w + 9] = max;
|
||||
let t = lossless(bits);
|
||||
let src = encode(&img, w, &table, t.seeds);
|
||||
let out = decode(&src, w, h, bits, &t).unwrap();
|
||||
assert_eq!(out, img.iter().map(|&v| v as u16).collect::<Vec<_>>(), "{bits}-bit");
|
||||
}
|
||||
}
|
||||
|
||||
fn lossy_table(bits: u32, order: ByteOrder) -> Vec<u8> {
|
||||
// version 0x44 0x20, seeds, 257 points of a concave curve, padding up to the split value (0)
|
||||
let mut t = vec![0x44, 0x20];
|
||||
let put = |t: &mut Vec<u8>, v: u16| t.extend_from_slice(&if order == ByteOrder::Big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
for _ in 0..4 {
|
||||
put(&mut t, 300);
|
||||
}
|
||||
put(&mut t, 257);
|
||||
let max = ((1u32 << bits) - 1) as f64;
|
||||
for i in 0..257u32 {
|
||||
put(&mut t, (max * (i as f64 / 64.0).min(1.0).sqrt()) as u16);
|
||||
}
|
||||
t.resize(624, 0);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_type2_curve_and_roundtrip() {
|
||||
for (bits, table) in [(12u32, LOSSY_12), (14, LOSSY_14)] {
|
||||
for order in [ByteOrder::Big, ByteOrder::Little] {
|
||||
let t = parse_table(&lossy_table(bits, order), order, bits).unwrap();
|
||||
assert_eq!(t.seeds, [[300, 300], [300, 300]]);
|
||||
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
|
||||
let step = (1usize << bits) / 256;
|
||||
assert_eq!(curve.len(), (1 << bits) + 1);
|
||||
assert_eq!(curve[64 * step], (1 << bits) - 1);
|
||||
assert!(curve.windows(2).all(|p| p[0] <= p[1]));
|
||||
// the encoded range is the curve's rising part
|
||||
let (w, h) = (48, 7);
|
||||
let img = image(w, h, 64 * step as i32 - 1, 7);
|
||||
let src = encode(&img, w, &table, t.seeds);
|
||||
let out = decode(&src, w, h, bits, &t).unwrap();
|
||||
assert_eq!(out, img.iter().map(|&v| curve[v as usize]).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_type1_full_curve() {
|
||||
let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5];
|
||||
for v in [0u16, 100, 400, 900, 1600] {
|
||||
t.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let parsed = parse_table(&t, ByteOrder::Big, 12).unwrap();
|
||||
assert_eq!(parsed.encoding, Encoding::Lossy(vec![0, 100, 400, 900, 1600]));
|
||||
let img = vec![0, 4, 1, 3, 2, 2, 4, 0];
|
||||
let src = encode(&img, 4, &LOSSY_12, parsed.seeds);
|
||||
assert_eq!(decode(&src, 4, 2, 12, &parsed).unwrap(), vec![0, 1600, 100, 900, 400, 400, 1600, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_and_malformed_tables() {
|
||||
let unsupported = |r: Result<DecodeTable>| matches!(r, Err(RawError::Unsupported(_)));
|
||||
let corrupt = |r: Result<DecodeTable>| matches!(r, Err(RawError::Corrupt(_)));
|
||||
let mut split = lossy_table(12, ByteOrder::Big);
|
||||
split[562..564].copy_from_slice(&345u16.to_be_bytes());
|
||||
assert!(unsupported(parse_table(&split, ByteOrder::Big, 12)));
|
||||
assert!(unsupported(parse_table(&[0x49, 0x30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ByteOrder::Big, 12)));
|
||||
assert!(unsupported(parse_table(&lossy_table(12, ByteOrder::Big), ByteOrder::Big, 16)));
|
||||
assert!(corrupt(parse_table(&[], ByteOrder::Big, 12)));
|
||||
assert!(corrupt(parse_table(&[0x46, 0x30, 8], ByteOrder::Big, 14)));
|
||||
assert!(corrupt(parse_table(&lossy_table(12, ByteOrder::Big)[..100], ByteOrder::Big, 12)));
|
||||
// 300 points don't divide the 12-bit range
|
||||
let mut odd = lossy_table(12, ByteOrder::Big);
|
||||
odd[10..12].copy_from_slice(&300u16.to_be_bytes());
|
||||
assert!(parse_table(&odd, ByteOrder::Big, 12).is_err());
|
||||
assert!(parse_table(&[0x44, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], ByteOrder::Big, 12).is_err());
|
||||
}
|
||||
|
||||
/// A minimal NEF: CFA SubIFD with one compressed strip, Exif maker note (`Nikon\0` v2 header + embedded TIFF in
|
||||
/// `mn_order`) holding the linearization table.
|
||||
fn nef_file(strip: Vec<u8>, w: u32, h: u32, bits: u16, table: Vec<u8>, mn_order: ByteOrder) -> Vec<u8> {
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
let mut mn = IfdBuilder::new();
|
||||
mn.set(0x0096, Value::Undefined(table));
|
||||
let mut note = b"Nikon\0\x02\x10\0\0".to_vec();
|
||||
note.extend(TiffWriter::new(mn_order, false).write(&[mn]).unwrap());
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(note));
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![t::compression::NIKON]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
|
||||
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] });
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into()));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
ifd0.add_sub_ifd(raw);
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_through_the_nef_container() {
|
||||
// lossless 14-bit, big-endian maker note
|
||||
let (w, h) = (40usize, 6usize);
|
||||
let img = image(w, h, 16383, 11);
|
||||
let mut table = vec![0x46, 0x30];
|
||||
for _ in 0..4 {
|
||||
table.extend_from_slice(&2048u16.to_be_bytes());
|
||||
}
|
||||
table.resize(46, 0);
|
||||
let src = encode(&img, w, &LOSSLESS_14, [[2048; 2]; 2]);
|
||||
let bytes = nef_file(src, w as u32, h as u32, 14, table, ByteOrder::Big);
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!(r.data, crate::RawData::U16(img.iter().map(|&v| v as u16).collect()));
|
||||
assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB");
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), r.info());
|
||||
// lossy 12-bit type 2, little-endian maker note (newer bodies)
|
||||
let table = lossy_table(12, ByteOrder::Little);
|
||||
let t = parse_table(&table, ByteOrder::Little, 12).unwrap();
|
||||
let Encoding::Lossy(curve) = &t.encoding else { panic!() };
|
||||
let img = image(w, h, 1023, 5);
|
||||
let src = encode(&img, w, &LOSSY_12, t.seeds);
|
||||
let r = crate::decode(&nef_file(src.clone(), w as u32, h as u32, 12, table.clone(), ByteOrder::Little)).unwrap();
|
||||
assert_eq!(r.data, crate::RawData::U16(img.iter().map(|&v| curve[v as usize]).collect()));
|
||||
// the split variant falls back to the preview
|
||||
let mut split = table;
|
||||
split[562..564].copy_from_slice(&3u16.to_le_bytes());
|
||||
let e = crate::decode(&nef_file(src, w as u32, h as u32, 12, split, ByteOrder::Little));
|
||||
assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupted_streams_error_never_panic() {
|
||||
let (w, h) = (64, 16);
|
||||
let t = lossless(14);
|
||||
let img = image(w, h, 16383, 3);
|
||||
let src = encode(&img, w, &LOSSLESS_14, t.seeds);
|
||||
// truncation: too short for the image, or ends early
|
||||
for cut in [0, 1, 10, src.len() / 2, src.len() - 40] {
|
||||
assert!(decode(&src[..cut], w, h, 14, &t).is_err(), "cut {cut}");
|
||||
}
|
||||
// garbage and bit flips: an error or (rarely) some image, never a panic
|
||||
let mut s = 12345u32;
|
||||
for i in 0..200 {
|
||||
let mut bad = src.clone();
|
||||
for _ in 0..1 + i % 8 {
|
||||
s = s.wrapping_mul(1_103_515_245).wrapping_add(12345);
|
||||
let at = (s >> 8) as usize % bad.len();
|
||||
bad[at] ^= 1 << (s % 8);
|
||||
}
|
||||
let _ = decode(&bad, w, h, 14, &t);
|
||||
let noise: Vec<u8> = (0..src.len()).map(|k| ((k as u32).wrapping_mul(2_654_435_761) >> (i % 24)) as u8).collect();
|
||||
let _ = decode(&noise, w, h, 14, &t);
|
||||
let _ = decode(&noise, w, h, 12, &lossless(12));
|
||||
}
|
||||
// all-ones never forms a valid lossy 12-bit code
|
||||
assert!(decode(&[0xff; 64], 8, 8, 12, &parse_table(&lossy_table(12, ByteOrder::Big), ByteOrder::Big, 12).unwrap()).is_err());
|
||||
// absurd dimensions are limited before allocating
|
||||
assert!(decode(&src, usize::MAX, 2, 14, &t).is_err());
|
||||
assert!(decode(&src, 1 << 20, 1 << 20, 14, &t).is_err());
|
||||
assert!(decode(&src, 0, 2, 14, &t).is_err());
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -99,6 +99,10 @@ impl<'a> Bits<'a> {
|
||||
self.skip(k);
|
||||
v
|
||||
}
|
||||
/// Bits handed out so far (may exceed the source length: zeros are read past its end).
|
||||
pub fn consumed_bits(&self) -> usize {
|
||||
self.pos * 8 - self.n as usize
|
||||
}
|
||||
pub fn overrun(&self) -> bool {
|
||||
self.pos > self.src.len() + 8
|
||||
}
|
||||
|
||||
+101
-2
@@ -23,8 +23,6 @@ const KNOWN_UNSUPPORTED: &[&str] = &[
|
||||
"rw2-panasonic-gx80", // "
|
||||
"rw2-panasonic-g9-b", // "
|
||||
"orf-olympus-em", // Olympus compressed ORF
|
||||
"nef-nikon-d5100-lossless", // Nikon Huffman NEF
|
||||
"nef-nikon-d7000-lossy", // "
|
||||
"sraw", // Canon sRAW / mRAW
|
||||
];
|
||||
|
||||
@@ -94,3 +92,104 @@ fn corpus_raw_decodes() {
|
||||
}
|
||||
eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (preview only)");
|
||||
}
|
||||
|
||||
/// Green-channel means of 32×32 blocks.
|
||||
fn block_means(img: &lightcraft_raw::RawImage) -> Vec<f64> {
|
||||
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
|
||||
let (w, h, b) = (img.width, img.height, 32);
|
||||
let mut out = Vec::new();
|
||||
for by in 0..h / b {
|
||||
for bx in 0..w / b {
|
||||
let (mut s, mut n) = (0f64, 0f64);
|
||||
for y in by * b..by * b + b {
|
||||
for x in (bx * b..bx * b + b).step_by(2) {
|
||||
s += d[y * w + x + (y & 1)] as f64;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
out.push(s / n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn correlation(a: &[f64], b: &[f64]) -> f64 {
|
||||
let n = a.len() as f64;
|
||||
let (ma, mb) = (a.iter().sum::<f64>() / n, b.iter().sum::<f64>() / n);
|
||||
let cov: f64 = a.iter().zip(b).map(|(x, y)| (x - ma) * (y - mb)).sum();
|
||||
let va: f64 = a.iter().map(|x| (x - ma).powi(2)).sum();
|
||||
let vb: f64 = b.iter().map(|y| (y - mb).powi(2)).sum();
|
||||
cov / (va * vb).sqrt()
|
||||
}
|
||||
|
||||
/// Which diagonal of each 2×2 cell (anchored at raw pixel (0, 0)) holds the green sites: the two greens of a Bayer
|
||||
/// cell see nearly the same light, so their mean absolute difference is far smaller than across the other diagonal.
|
||||
/// Returns `true` when green sits at (0, 0)/(1, 1) (GBRG/GRBG), `false` for (1, 0)/(0, 1) (RGGB/BGGR).
|
||||
fn green_on_main_diagonal(img: &lightcraft_raw::RawImage) -> bool {
|
||||
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
|
||||
let (w, a) = (img.width, img.active_area);
|
||||
let (mut main, mut anti) = (0f64, 0f64);
|
||||
for y in ((a.y + 2) & !1..a.y + a.height - 2).step_by(2) {
|
||||
for x in ((a.x + 2) & !1..a.x + a.width - 2).step_by(2) {
|
||||
let v = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as f64;
|
||||
main += (v(0, 0) - v(1, 1)).abs();
|
||||
anti += (v(1, 0) - v(0, 1)).abs();
|
||||
}
|
||||
}
|
||||
main < anti
|
||||
}
|
||||
|
||||
/// Canon CR2 colour-filter layouts differ by model (issue #85); the decoder reads them from the `CR2CFAPattern` tag.
|
||||
/// The expected layouts were checked visually (natural colours vs. the embedded preview) and agree with the
|
||||
/// green-diagonal statistic of the mosaic itself.
|
||||
#[test]
|
||||
fn corpus_cr2_cfa_patterns() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let cases = [
|
||||
("cr2-canon-40d.cr2", "RGGB"),
|
||||
("cr2-canon-550d.cr2", "GBRG"),
|
||||
("cr2-canon-5d2.cr2", "GBRG"),
|
||||
("cr2-canon-5d3.cr2", "RGGB"),
|
||||
("cr2-canon-5dsr.cr2", "RGGB"),
|
||||
("cr2-canon-6d.cr2", "RGGB"),
|
||||
("cr2-canon-7d.cr2", "GBRG"),
|
||||
("cr2-canon-80d.cr2", "RGGB"),
|
||||
];
|
||||
let mut seen = 0;
|
||||
for (name, want) in cases {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
};
|
||||
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let got = img.cfa.as_ref().map(|c| c.name()).unwrap_or_default();
|
||||
assert_eq!(got, want, "{name}: CFA layout");
|
||||
assert_eq!(green_on_main_diagonal(&img), want.starts_with('G'), "{name}: mosaic statistics disagree with {want}");
|
||||
seen += 1;
|
||||
}
|
||||
eprintln!("CR2 CFA layouts 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]
|
||||
fn corpus_nef_compressed_matches_uncompressed() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef")))
|
||||
else {
|
||||
eprintln!("skip: D5100 NEF pair absent");
|
||||
return;
|
||||
};
|
||||
let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap());
|
||||
assert_eq!((a.width, a.height, a.bits), (b.width, b.height, b.bits));
|
||||
let r = correlation(&block_means(&a), &block_means(&b));
|
||||
eprintln!("D5100 lossless vs uncompressed: block correlation {r:.4}");
|
||||
assert!(r > 0.98, "correlation {r}");
|
||||
// the lossy 12-bit D7000 file decodes into the curve's range and isn't flat
|
||||
if let Ok(c) = std::fs::read(dir.join("nef-nikon-d7000-lossy12.nef")) {
|
||||
let c = decode(&c).unwrap();
|
||||
let m = block_means(&c);
|
||||
let (lo, hi) = m.iter().fold((f64::MAX, 0f64), |(l, h), &v| (l.min(v), h.max(v)));
|
||||
assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}");
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,11 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"activeMask": app.session.active_mask,
|
||||
"widgetCount": app.widgets.len(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()},
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
||||
"status": app.ui.status,
|
||||
"unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})),
|
||||
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
|
||||
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
|
||||
"memory": memory(app),
|
||||
|
||||
@@ -33,6 +33,8 @@ mod tests_masking;
|
||||
mod tests_panels;
|
||||
#[cfg(test)]
|
||||
mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_unsaved;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
@@ -281,6 +283,26 @@ impl LightcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Announce when saving the library starts failing (changes then live only in memory and are
|
||||
/// retried) and when it works again; the top bar's cloud icon shows the state meanwhile.
|
||||
fn save_status(&mut self, ctx: &egui::Context) {
|
||||
let unsaved = self.session.unsaved().map(|(n, e)| (n, e.to_string()));
|
||||
match (unsaved, self.ui.unsaved_seen) {
|
||||
(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, true) => {
|
||||
self.ui.unsaved_seen = false;
|
||||
self.toast(ctx, "Library saved");
|
||||
}
|
||||
(Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry
|
||||
(None, false) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
|
||||
let t = ctx.input(|i| i.time);
|
||||
self.ui.toast = Some((text.into(), t + 1.4));
|
||||
@@ -448,6 +470,7 @@ impl LightcraftApp {
|
||||
import::poll_scan(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
self.preview_build_status(ctx);
|
||||
self.save_status(ctx);
|
||||
self.slideshow_tick(ctx);
|
||||
// back from an external editor: pick up the files it saved
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
|
||||
@@ -856,7 +856,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
|
||||
("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)),
|
||||
("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())),
|
||||
("GPU".to_string(), gpu.unwrap_or_else(|| "none (CPU rendering)".into())),
|
||||
(
|
||||
"GPU".to_string(),
|
||||
gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() {
|
||||
Some(why) => format!("none (CPU rendering): {why}"),
|
||||
None => "none (CPU rendering)".into(),
|
||||
}),
|
||||
),
|
||||
("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }),
|
||||
("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)),
|
||||
("Preview size".to_string(), format!("{} px", app.ui.settings.preview_edge)),
|
||||
@@ -872,6 +878,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms),
|
||||
));
|
||||
rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms)));
|
||||
if let Some(f) = lightcraft_engine::gpu::last_fallback() {
|
||||
rows.push(("Last GPU fallback".into(), f));
|
||||
}
|
||||
let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>());
|
||||
if p.get("open").and_then(Value::as_bool).unwrap_or(true) {
|
||||
app.ui.dialog = Some(Dialog::SystemInfo { rows });
|
||||
|
||||
@@ -342,7 +342,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
heading(ui, t, "Rendering");
|
||||
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
|
||||
let status = if !gpu::available() {
|
||||
"No usable GPU found: rendering on the CPU".to_string()
|
||||
match gpu::unavailable_reason() {
|
||||
Some(why) => format!("Rendering on the CPU: {why}"),
|
||||
None => "No usable GPU found: rendering on the CPU".to_string(),
|
||||
}
|
||||
} else {
|
||||
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
|
||||
};
|
||||
@@ -376,6 +379,19 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let _ = app.run("library.clearPreviews", json!({}));
|
||||
}
|
||||
});
|
||||
heading(ui, t, "Local folders");
|
||||
row(ui, t, "Forget unchanged photos", |ui| {
|
||||
let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")];
|
||||
let mut v = app.session.forget_local_days;
|
||||
if choices(ui, "settingsForgetLocal", &opts, &mut v) {
|
||||
let _ = app.run("library.preferences", json!({"forgetLocalDays": v}));
|
||||
}
|
||||
});
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.",
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
smart_previews(app, ui, t);
|
||||
}
|
||||
|
||||
@@ -111,9 +111,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// right icons
|
||||
let mut x = full.right() - 18.0;
|
||||
// saving is failing: the cloud icon turns into a warning until a save succeeds
|
||||
let unsaved = app.session.unsaved().map(|(n, e)| {
|
||||
format!(
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "it" } else { "them" }
|
||||
)
|
||||
});
|
||||
let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed");
|
||||
for (id, icon, tip, cmd) in [
|
||||
("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"),
|
||||
("cloud", Icon::Cloud, "Local library — no cloud account needed", ""),
|
||||
("cloud", Icon::Cloud, cloud_tip, ""),
|
||||
("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"),
|
||||
("share", Icon::Share, "Export", "dialog.export"),
|
||||
("bell", Icon::Bell, "Activity", "panel.activity"),
|
||||
@@ -121,7 +130,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(tip);
|
||||
register(ui.ctx(), format!("icon:{id}"), r);
|
||||
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
|
||||
let warn = id == "cloud" && unsaved.is_some();
|
||||
let colour = if warn {
|
||||
t.caution
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
};
|
||||
paint(ui.painter(), r.shrink(5.0), icon, colour);
|
||||
if warn {
|
||||
let c = r.right_top() + vec2(-5.0, 6.0);
|
||||
ui.painter().circle_filled(c, 6.0, t.reject);
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas);
|
||||
register(ui.ctx(), "indicator:unsaved", r);
|
||||
}
|
||||
if resp.clicked() && !cmd.is_empty() {
|
||||
let _ = app.run(cmd, json!({}));
|
||||
}
|
||||
|
||||
@@ -203,6 +203,10 @@ pub struct UiState {
|
||||
/// The Build Previews run last announced (its identity, finished?).
|
||||
#[serde(skip)]
|
||||
pub preview_build_seen: Option<(usize, bool)>,
|
||||
/// Saving the library is failing (announced with a toast; the top bar shows a warning until
|
||||
/// a later save succeeds).
|
||||
#[serde(skip)]
|
||||
pub unsaved_seen: bool,
|
||||
pub view: ViewMode,
|
||||
pub left_panel: bool,
|
||||
pub right: RightPanel,
|
||||
@@ -505,6 +509,7 @@ impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
preview_build_seen: None,
|
||||
unsaved_seen: false,
|
||||
luminance_map_restore: None,
|
||||
view: ViewMode::Detail,
|
||||
left_panel: false,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A failed save is visible: the command reports the error, a toast says the change is only in
|
||||
//! memory, and the top bar shows a warning until a later save succeeds.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::{MemStore, Store};
|
||||
use lightcraft_engine::library::LibraryStores;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Appends fail while `fail` is set.
|
||||
#[derive(Clone, Default)]
|
||||
struct Flaky {
|
||||
files: MemStore,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Store for Flaky {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"flaky".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_save_is_reported_and_shown_until_a_save_succeeds() {
|
||||
let store = Flaky::default();
|
||||
let mut s = lightcraft_engine::Session::new();
|
||||
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.step();
|
||||
let has_indicator = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "indicator:unsaved");
|
||||
assert!(!has_indicator(&h));
|
||||
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Kept"}}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(
|
||||
r["error"].as_str().unwrap().contains("saved in memory but not written to disk: ") && r["error"].as_str().unwrap().contains("disk full"),
|
||||
"{r}"
|
||||
);
|
||||
assert_eq!(h.app.session.catalog.albums().count(), 1, "the change is applied");
|
||||
h.step();
|
||||
h.step();
|
||||
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
|
||||
assert!(toast.contains("not written to disk"), "{toast}");
|
||||
assert!(has_indicator(&h));
|
||||
let inspect = h.request("ui.inspect", json!({}), T);
|
||||
assert_eq!(inspect["result"]["unsaved"]["ops"], 1, "{inspect}");
|
||||
|
||||
// the disk comes back: the next command writes the queue, the warning goes away
|
||||
store.fail.store(false, Ordering::SeqCst);
|
||||
let r = h.request("engine.execute", json!({"command": "library.info"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!has_indicator(&h));
|
||||
assert_eq!(h.app.ui.toast.clone().map(|t| t.0).as_deref(), Some("Library saved"));
|
||||
}
|
||||
@@ -21,7 +21,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||||
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
|
||||
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` |
|
||||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||||
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
|
||||
@@ -37,6 +37,18 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||||
| `app.quit` | — | Close the app |
|
||||
|
||||
### When the library can't be saved
|
||||
|
||||
With a persistent library, every command that changes something is written to the catalog journal (fsynced) before
|
||||
it replies. If that write fails (disk full, volume gone, permissions), the command replies `ok: false` with
|
||||
`"saved in memory but not written to disk: <reason>; LightCraft will retry"`. The change itself **is** applied (and
|
||||
undoable) and stays queued: the next command, and the app's frame loop every couple of seconds, retry the write, so
|
||||
nothing is lost once the disk is writable again — unless the app quits first. Meanwhile `ui.inspect` → `unsaved`
|
||||
is `{ops, error}` (else `null`), `library.info` reports `unsavedOps` / `unsavedError`, and the top bar's cloud icon
|
||||
shows a warning (widget `indicator:unsaved`). Queries and commands that change nothing still succeed. A failed
|
||||
compaction (snapshot) is not a failed command — the log is kept whole — and only shows in `library.info` →
|
||||
`lastError`.
|
||||
|
||||
## Headless rendering (no window, no GPU)
|
||||
|
||||
The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by
|
||||
|
||||
+61
-5
@@ -10,9 +10,9 @@ pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled
|
||||
available; grid/filmstrip thumbnails (many small jobs in parallel) stay on the CPU.
|
||||
- Anything the GPU path cannot do returns `None` and the CPU renders instead: no adapter (CI
|
||||
machines, software-only adapters), `LIGHTCRAFT_GPU=0` (whole process), the `app.gpu {enabled}`
|
||||
command (runtime preference; `ui.inspect` → `perf.gpu` shows the adapter), an output larger than
|
||||
the device's storage-buffer limit, or a device error / panic during a render (the process then
|
||||
stays on the CPU).
|
||||
command (runtime preference; `ui.inspect` → `perf.gpu` shows the adapter), a buffer larger than
|
||||
the device's storage-buffer limit, or a render the device did not complete correctly (see
|
||||
[Limits, failures and the CPU fallback](#limits-failures-and-the-cpu-fallback)).
|
||||
- wasm32: the crate compiles to the CPU fallback (WebGPU needs asynchronous device creation and a
|
||||
device shared with the canvas — open item below).
|
||||
|
||||
@@ -44,6 +44,59 @@ pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled
|
||||
per pass cost as much as the passes.
|
||||
- `LIGHTCRAFT_PROFILE=1` prints GPU stage timings (each stage is then submitted and waited for).
|
||||
|
||||
## Limits, failures and the CPU fallback
|
||||
Issue #78: on an Intel UHD (ICL GT1) iGPU with Mesa/Vulkan, exports from the desktop app came out
|
||||
as valid JPEGs that were entirely black, while previews, `lightcraft-cli render` and
|
||||
`LIGHTCRAFT_GPU=0` were fine — and the GPU export took ~15 s against ~3 s on the CPU. The readback
|
||||
returned a buffer the per-pixel stage had never written, with no error. The likeliest cause: the
|
||||
whole export was recorded as one command submission lasting seconds on that GPU, and the driver
|
||||
reset it (GPU hang check / preemption timeout — the app's own window keeps the GPU busy, the CLI
|
||||
doesn't) without the error reaching wgpu. wgpu also reports a lost device only through the
|
||||
device-lost callback, which was not set. So a GPU render now refuses to hand back anything it
|
||||
cannot vouch for:
|
||||
|
||||
- **Short submissions.** A render submits after every stage and every 16 M kernel invocations
|
||||
(`FLUSH_INVOCATIONS`); the per-pixel stage runs in bands of ~4 MP. Every submission on a slow
|
||||
iGPU stays short enough to be preempted and never trips a watchdog (Windows TDR ~2 s, i915).
|
||||
Buffers released by earlier stages are reused by later ones, lowering peak device memory.
|
||||
- **Error scopes.** Each render runs inside out-of-memory, validation and internal error scopes
|
||||
(thread-local in wgpu): errors fail that render instead of disappearing into the uncaptured
|
||||
handler. The device-lost callback and the uncaptured handler (other threads) stop GPU use.
|
||||
- **Readback.** The wait has a timeout (60 s); a failed wait, map error or missing map callback
|
||||
fails the render (before, a lost device left the readback waiting forever).
|
||||
- **Limits.** The device is created with the adapter's limits; every buffer is checked against
|
||||
`min(max_storage_buffer_binding_size, max_buffer_size)` when it is created. A render with a
|
||||
buffer over the limit (24 MP RGB f32 = 275 MiB; a 128 MiB WebGPU-default device; many masks)
|
||||
falls back for that render, without a device error. Dispatches stay under 65535 workgroups per
|
||||
dimension (`groups1` folds 1-D kernels onto two axes). Exports over the limit are not tiled:
|
||||
most stages need neighbourhoods tens to hundreds of pixels wide (guided filters at clarity /
|
||||
base radius, dehaze dark channel) and some need global values (airlight percentile), so tiles
|
||||
would need wide overlaps and a two-pass plan — the CPU renders those exports instead.
|
||||
- **Sanity checks on the result.** The output buffer is cleared before the per-pixel stage, which
|
||||
writes alpha 255 everywhere: any pixel without it means work did not run, and the GPU is not used
|
||||
again (`incomplete image`). An entirely black result (n ≥ 4096 pixels) is re-rendered on the CPU
|
||||
for that render only — a really black photo costs one extra CPU render, nothing else; there are
|
||||
no false positives because the CPU result is what is returned.
|
||||
|
||||
| failure | this render | later renders |
|
||||
|---|---|---|
|
||||
| buffer over the device limit | CPU | GPU |
|
||||
| out of device memory | CPU (buffer pool emptied) | GPU |
|
||||
| entirely black result | CPU | GPU |
|
||||
| validation / internal error, device lost, readback failure or timeout, incomplete image, panic | CPU | CPU (whole process) |
|
||||
|
||||
**Diagnosing.** `ui.inspect` → `perf.gpu` (adapter in use), `perf.gpuReason` (why renders don't use
|
||||
the GPU, e.g. `"disabled by LIGHTCRAFT_GPU=0"`, `"software adapter (llvmpipe (LLVM 21.1.8, 256 bits))
|
||||
skipped: …"`, `"stopped after a GPU failure: device lost …"`), `perf.gpuFallback` (the latest render
|
||||
redone on the CPU and why, e.g. `"6000×4000: the GPU returned an incomplete image (24000000 of
|
||||
24000000 pixels unwritten); the GPU is not used again"`). The same in `app.gpu` (`reason`,
|
||||
`lastFallback`), Help ▸ System Info and Settings ▸ Performance. `LIGHTCRAFT_PROFILE=1` prints the
|
||||
fallback with the stage timings; `RUST_LOG=warn` logs it.
|
||||
**Reproducing a smaller GPU:** `LIGHTCRAFT_GPU_LIMITS=webgpu` (or `downlevel`) creates the device
|
||||
with 128 MiB storage bindings / 256 MiB buffers, `LIGHTCRAFT_GPU_LIMITS=<n>` with n MiB / 2n MiB.
|
||||
Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/fallback.rs`,
|
||||
`export_falls_back_to_the_cpu_when_gpu_work_is_lost`).
|
||||
|
||||
## Correctness: CPU oracle and equivalence tests
|
||||
`crates/gpu/tests/equivalence.rs` renders the same settings on both and compares 8-bit sRGB:
|
||||
bounds **mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB** per channel. Measured (Apple M4 Pro, Metal):
|
||||
@@ -120,8 +173,11 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo
|
||||
reading back); share the device with eframe.
|
||||
- WebGPU in the browser build (async init, single device per canvas).
|
||||
- Defringe and spot removal kernels; Sky/Subject heuristics (replaced by the segmenter in M12).
|
||||
- Tiling for sources / outputs beyond the buffer limit (60 MP+ on smaller adapters): tile with
|
||||
overlap equal to the largest filter radius.
|
||||
- Tiling for sources / outputs beyond the buffer limit (24 MP+ on WebGPU-default limits, 60 MP+
|
||||
on most desktop adapters; these render on the CPU now): tile with overlap equal to the largest
|
||||
filter radius, with global values (airlight) from a first pass.
|
||||
- Integrated GPUs much slower than the CPU (issue #78's GT1): a measured per-device choice of the
|
||||
faster path for exports.
|
||||
- Large-radius blurs at full size (dehaze dark channel at 24 MP) dominate the export: a summed-area
|
||||
table or a downsampled dark channel would cut them further.
|
||||
- GPU histogram (atomics) to skip the CPU pass over the readback.
|
||||
@@ -141,6 +141,10 @@ results with `isError: true` so the model can read and correct them; malformed J
|
||||
standard error codes (-32700 parse, -32600 invalid request, -32601 method not found, -32602
|
||||
invalid params, -32002 resource not found).
|
||||
|
||||
With `--library`, a command whose change can't be written to disk (disk full, …) is an error too:
|
||||
`saved in memory but not written to disk: <reason>; LightCraft will retry` — the change is applied in the session and
|
||||
written by the next successful save (see [control-protocol.md](control-protocol.md#when-the-library-cant-be-saved)).
|
||||
|
||||
## One-shot commands: `lightcraft-cli run`
|
||||
|
||||
For agents that prefer a shell over an MCP session: run any chain of commands in one process and read one JSON
|
||||
|
||||
+31
-14
@@ -21,13 +21,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
<!-- parity:summary -->
|
||||
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| A. Import (IMP) | 11 | 2 | 1 | 1 | 3/4 (75%) | 5/5 (100%) |
|
||||
| A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) |
|
||||
| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) |
|
||||
| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 1 | 3 | 0 | 3/3 (100%) | 2/3 (67%) |
|
||||
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
|
||||
@@ -44,23 +44,37 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 15 | 1 | 2 | 1 | 8/8 (100%) | 6/6 (100%) |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 15 | 2 | 2 | 1 | 8/8 (100%) | 6/7 (86%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 30 | 9 | 40 | 9 | — | 20/21 (95%) |
|
||||
| **Total** | 386 | 25 | 91 | 37 | 194/198 (98%) | 138/146 (95%) |
|
||||
| **Total** | 386 | 28 | 91 | 37 | 194/200 (97%) | 138/147 (94%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.4%** of 502 in-scope rows — P0 99.0% of 198 · P1 96.2% of 146 · P2 39.2% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 505 in-scope rows — P0 98.5% of 200 · P1 95.9% of 147 · P2 39.2% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
||||
Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on.
|
||||
Ordered by user impact, then tier, then effort. The checklist above counts features that *exist*; these are the gaps
|
||||
that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)).
|
||||
Take the first one nobody is working on.
|
||||
|
||||
1. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today: such photos are marked
|
||||
`preview_only` and the UI says they are edited from the camera's embedded JPEG, issue #10); HEIC/AVIF decode (no
|
||||
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
|
||||
2. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Every non-DNG raw is developed with a neutral
|
||||
matrix today, so colours are muted. Start by fitting each camera to its own embedded JPEG and using matrices the
|
||||
files carry themselves; never Adobe data.
|
||||
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, 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
|
||||
colour-filter layout, fixed in #85 by reading the file's own tag).
|
||||
4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`),
|
||||
then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it.
|
||||
5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR):
|
||||
blocked on a model strategy (permissively licensed weights or our own training, pure-Rust inference). A maintainer
|
||||
decision, not just engineering.
|
||||
7. **HDR** (Q. HDR, LR-EXP-HDR), **video** (R. Video), **Classic output modules** (Map view, Book, Slideshow module,
|
||||
Print, publish): large, well understood, lower priority than 1–5.
|
||||
|
||||
## Shortcuts: conflicts and missing bindings
|
||||
|
||||
@@ -108,9 +122,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
|
||||
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
|
||||
| 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`) |
|
||||
| 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, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (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 |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW, NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, 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 |
|
||||
| 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: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
|
||||
| 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) |
|
||||
@@ -261,6 +276,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` | DNG files use the colour matrices they carry; every other raw uses 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 |
|
||||
@@ -472,7 +488,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | |
|
||||
| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved |
|
||||
| LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits |
|
||||
| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | |
|
||||
| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | |
|
||||
@@ -480,7 +496,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |
|
||||
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre |
|
||||
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |
|
||||
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback |
|
||||
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; no tiling beyond the buffer limit (CPU) |
|
||||
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric |
|
||||
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) |
|
||||
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |
|
||||
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
with a counterpart here come along, the rest is listed.
|
||||
- 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.
|
||||
|
||||
### Reliability
|
||||
- Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other
|
||||
models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter
|
||||
layout is read from each file instead of assumed.
|
||||
- Exports are never black because of the GPU (issue #78): a GPU render that runs out of device
|
||||
memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back
|
||||
incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU
|
||||
in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`
|
||||
(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't
|
||||
used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.
|
||||
|
||||
### Library
|
||||
- Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.
|
||||
- Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.
|
||||
@@ -21,6 +32,12 @@
|
||||
- Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.
|
||||
- Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.
|
||||
- Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.
|
||||
- A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports
|
||||
"saved in memory but not written to disk", the top bar shows a warning, and LightCraft keeps retrying until the
|
||||
save goes through.
|
||||
- Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once
|
||||
their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them
|
||||
again. Change the period (or turn it off) in Settings → Performance.
|
||||
|
||||
### Editing
|
||||
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
|
||||
@@ -31,3 +48,7 @@
|
||||
- Slideshow, second window, All Metadata, System Info.
|
||||
- Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.
|
||||
- Lossy DNG files and Smart Previews open as raw photos.
|
||||
- Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)
|
||||
now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the
|
||||
camera no longer gets baked in. Photos already imported as "preview only" switch over on Reload. (Files that
|
||||
Nikon splits into two differently compressed halves still use the preview for now.)
|
||||
@@ -234,6 +234,13 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
|
||||
("arw-sony-a7m4-lossless-l.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW"),
|
||||
("arw-sony-a7m4-lossless-m.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW"),
|
||||
("arw-sony-a7m4-lossless-s.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW"),
|
||||
// CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples
|
||||
("cr2-canon-40d.cr2", "https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2"),
|
||||
("cr2-canon-550d.cr2", "https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2"),
|
||||
("cr2-canon-5d2.cr2", "https://raw.pixls.us/data/Canon/EOS%205D%20Mark%20II/08.canon.raw.cr2"),
|
||||
("cr2-canon-5dsr.cr2", "https://raw.pixls.us/data/Canon/EOS%205DS%20R/_DSR2002.CR2"),
|
||||
("cr2-canon-6d.cr2", "https://raw.pixls.us/data/Canon/EOS%206D/EOS_6D_RAW.CR2"),
|
||||
("cr2-canon-7d.cr2", "https://raw.pixls.us/data/Canon/EOS%207D/RAW_CANON_EOS_7D-raw.CR2"),
|
||||
("cr2-canon-5d3-sraw2.cr2", "https://raw.pixls.us/getfile.php/773/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%20sRAW2%20%28sRAW%29.CR2"),
|
||||
("cr2-canon-5d3.cr2", "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2"),
|
||||
("cr2-canon-80d.cr2", "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2"),
|
||||
|
||||
Reference in new issue
Block a user