Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 16dbb90499 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:34:55 -04:00
Brandon ThomasandClaude Opus 5.5 73c61aae44 FreeBSD CI: two flaky ui-egui tests made deterministic
settings_dialog_by_keyboard_and_clicks: the Performance tab's GPU status
hint reads process-wide GPU state, which other tests flip through
app.gpu. Without a GPU driver (the FreeBSD VM) the reason is a long wgpu
message that wraps to two lines, while "disabled by the GPU rendering
preference" is one, so the controls below moved by 20 px between the
frame that located "button:settingsMemory-2" and the frame that clicked
it. The status is now one truncated line with the full text on hover;
gpu_status_never_moves_the_performance_controls checks the buttons stay
put (it fails without the fix when no Vulkan driver loads).

offline_originals_never_block_a_frame: the slow NAS probe was a 400 ms
sleep judged by a 300 ms wall-clock frame budget, which a loaded QEMU VM
missed (1.06 s). The probe now waits on a gate the test opens; frames
must finish while it is closed (a watchdog records any frame that had to
wait), so no timing budget is needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:04 -04:00
Brandon Thomas a4b53b28cc Merge pull request #1682 from storytold/claude/integrate-batch-35
Integration batch 35: 61 reviewed community PRs (never-crash, raw decode, imports, UI fixes)
2026-10-14 02:04:11 -04:00
Brandon ThomasandClaude Opus 5.5 180d400cdd Batch 35: one header line per tracker doc, one 2026-10-13 section in What's New, parity summary refreshed, rustfmt after merges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:55 -04:00
Brandon Thomas 5fe332c7b4 Merge PR #1570 (updated head)
# Conflicts:
#	README.md
#	crates/raw/src/vendor/nef_lens.rs
#	docs/gaps.md
#	docs/nikon-lens-corrections.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-14 01:54:50 -04:00
Brandon Thomas 195fb9698b Merge PR #1427 (updated head)
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 01:53:54 -04:00
Brandon ThomasandClaude Opus 5.5 a5989d6c0d Batch 35: #1132's cancel test takes the four-part fake import #1412 left
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:41:33 -04:00
Brandon Thomas 48b9a5e21f Merge PR #1132
# Conflicts:
#	crates/engine/src/lightroom_job.rs
2026-10-14 01:40:32 -04:00
Brandon Thomas 418f6255be Merge PR #1543
# Conflicts:
#	crates/engine/src/export.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 01:40:03 -04:00
Brandon Thomas 9085eb4b23 Merge PR #1653
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 01:39:50 -04:00
Brandon Thomas 694ce5b42b Merge PR #1537
# Conflicts:
#	docs/gaps.md
2026-10-14 01:39:50 -04:00
Brandon Thomas aa0f9a5666 Merge PR #1536
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 01:39:49 -04:00
Brandon Thomas 5e1213c963 Merge PR #1598 2026-10-14 01:39:06 -04:00
Brandon Thomas ea7f799607 Merge PR #1597 2026-10-14 01:39:05 -04:00
Brandon Thomas 71a43d55ce Merge PR #1661 2026-10-14 01:39:04 -04:00
Brandon Thomas 89b6a34595 Merge PR #1652 2026-10-14 01:39:04 -04:00
Brandon Thomas cc7179c560 Merge PR #1667
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:39:03 -04:00
Brandon Thomas 4fe0466942 Merge PR #1666
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:39:02 -04:00
Brandon Thomas e783f705dc Merge PR #1483 2026-10-14 01:39:00 -04:00
Brandon Thomas 35c4290d14 Merge PR #1477 2026-10-14 01:38:59 -04:00
Brandon Thomas 0a68a21e03 Merge PR #1459
# Conflicts:
#	crates/raw/tests/dng.rs
2026-10-14 01:38:59 -04:00
Brandon Thomas 61d96fc037 Merge PR #1655 2026-10-14 01:38:58 -04:00
Brandon Thomas 93bd81a480 Merge PR #1557
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:57 -04:00
Brandon Thomas 1dd41b1872 Merge PR #1550
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:56 -04:00
Brandon Thomas 4d0e11651a Merge PR #1546
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 01:38:55 -04:00
Brandon Thomas c8f1ecd4d3 Merge PR #1544 2026-10-14 01:38:54 -04:00
Brandon Thomas deb7831083 Merge PR #1542
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:53 -04:00
Brandon Thomas b54a2e9793 Merge PR #1541 2026-10-14 01:38:52 -04:00
Brandon Thomas 992e67188b Merge PR #1539
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:51 -04:00
Brandon Thomas b18ce72b61 Merge PR #1535
# Conflicts:
#	crates/raw/src/vendor/pef.rs
2026-10-14 01:38:49 -04:00
Brandon Thomas 1c65549d10 Merge PR #1533
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 01:38:48 -04:00
Brandon ThomasandClaude Opus 5.5 9b7d2e6ae2 Batch 35: #1443's face-tile test passes the preview edge #1410 added
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:34:10 -04:00
Brandon Thomas 07f73c44da Merge PR #1443
# Conflicts:
#	crates/engine/src/files.rs
#	docs/raw-preview-reads.md
#	docs/whats-new.md
2026-10-14 01:29:30 -04:00
Brandon Thomas b4d5b47617 Merge PR #1453 (rebased onto main) 2026-10-14 01:27:32 -04:00
skybluu bcfda31678 Merge current main into DNG identity fix 2026-10-13 21:34:33 -05:00
skybluu 83a89ff271 Merge current main into source build guidance 2026-10-13 21:31:07 -05:00
sanyamsingh001andCodex 3489c45cf9 LR-MASK-OBJECTS: integrate model folder retry with pinned main baseline
Preserve model setup and retry behavior alongside the upstream folder
opening callback. Keep the Linux opener asynchronous.

Co-authored-by: Codex <[email protected]>
2026-10-14 02:19:43 +00:00
skybluu f12cf65ab7 LR-BUILD: document command-local low-memory release overrides (#1196) 2026-10-13 21:19:28 -05:00
skybluu ce607d5a49 LR-LIB-CONVERT: avoid duplicated DNG camera make (#840) 2026-10-13 21:13:51 -05:00
Marco Arnulfo aaf7f2cc47 Loupe: the filter pill sits as far from the left edge as from the top (fixes #1645) 2026-10-14 02:39:16 +02:00
eliets2 ddc347cc55 Reject non-finite denoise inputs and derived pixels 2026-10-14 02:11:50 +03:00
Marco Arnulfo 528a464740 Search: clearing the filters empties the search field too (fixes #1648) 2026-10-14 01:04:42 +02:00
eliets2 3ad5eadab2 LR-REM-HEAL: checkpoint zero-feather spot edge correction
Treat a zero-width feather transition as a hard edge so exact-radius pixels remain unchanged, including at zero opacity. Add actual image regressions for all spot modes, image corners and two clone output sizes.

Validation: both new regressions failed on the original implementation and pass here. Formatting, workspace Clippy, HEIF and a generated-photo app smoke test pass. The unfiltered workspace gate remains blocked by the existing all-masks overlay test tracked by #1599; later parity, layers, assets and WASM stages are pending. This is a source checkpoint, not a green release candidate.

Contributor: @eliets2
2026-10-14 00:51:56 +03:00
Navid Zaman Khan aa4e3ce822 fix(ui): propagate find-missing errors and timeouts 2026-10-13 22:45:47 +06:00
Michael Anderson 1ee751ebdf LR-EDIT-OPTICS-PROFILE: Apply Nikon's in-file distortion correction for the Z 6II, Z 8 and Z f
Read the polynomial, zoom and optical center offset from NikonNEFInfo.
2026-10-13 11:10:46 -05:00
Brandon Thomas d71e7d11c1 Merge PR #1429
# Conflicts:
#	crates/ui-egui/src/panels/left.rs
2026-10-13 11:52:08 -04:00
Brandon Thomas 93f8ee7c0d Merge PR #1427
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-13 11:52:07 -04:00
Brandon Thomas ce3fad5a77 Merge PR #1567
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-13 11:52:01 -04:00
Brandon Thomas f478b000de Merge PR #1580 2026-10-13 11:51:51 -04:00
Brandon Thomas cd59698798 Merge PR #1583
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:24 -04:00
Brandon Thomas e067581ff3 Merge PR #1581 2026-10-13 11:49:23 -04:00
Brandon Thomas 3f9b3b20cb Merge PR #1579
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:22 -04:00
Brandon Thomas 3b848a2093 Merge PR #1578 2026-10-13 11:49:21 -04:00
Brandon Thomas 13c32ef1bb Merge PR #1577 2026-10-13 11:49:21 -04:00
Brandon Thomas 05dc7374d8 Merge PR #1576 2026-10-13 11:49:20 -04:00
Brandon Thomas 9f8a17277e Merge PR #1574 2026-10-13 11:49:20 -04:00
Brandon Thomas 0a2b2afb2e Merge PR #1573 2026-10-13 11:49:19 -04:00
Brandon Thomas 2ddc147f60 Merge PR #1572 2026-10-13 11:49:18 -04:00
Brandon Thomas 7942df9dd4 Merge PR #1571 2026-10-13 11:49:18 -04:00
Brandon Thomas 642662d90f Merge PR #1570
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 11:49:17 -04:00
Brandon Thomas f62be0925c Merge PR #1569 2026-10-13 11:49:16 -04:00
Brandon Thomas 2294329b0c Merge PR #1565
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 11:49:15 -04:00
Brandon Thomas 7627b2b2b8 Merge PR #1563
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:49:14 -04:00
Brandon Thomas 5ece09aa6c Merge PR #1562 2026-10-13 11:49:13 -04:00
Brandon Thomas 2fd0530beb Merge PR #1561
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:13 -04:00
Brandon Thomas 8bcbcd918b Merge PR #1560
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:12 -04:00
Brandon Thomas 6b00656df8 Merge PR #1559
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:11 -04:00
Brandon Thomas 2341132b35 Merge PR #1558
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:11 -04:00
Brandon Thomas c6b2686ba0 Merge PR #1410
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:05 -04:00
Brandon Thomas 02bdad38d2 Merge PR #1439
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:48:58 -04:00
Brandon Thomas 6d124d701b Merge PR #1451 2026-10-13 11:48:38 -04:00
Brandon Thomas d823d155b2 Merge PR #1421 2026-10-13 11:48:37 -04:00
Brandon Thomas f0493fa11b Merge PR #1419 2026-10-13 11:48:36 -04:00
Brandon Thomas 836bc449f0 Merge PR #1454 2026-10-13 11:48:35 -04:00
Brandon Thomas ac1eaf1d68 Merge PR #1452 2026-10-13 11:48:34 -04:00
Brandon Thomas 03b6c8a95d Merge PR #1423 2026-10-13 11:48:34 -04:00
Brandon Thomas cd5f16d5ac Merge PR #1445 2026-10-13 11:48:33 -04:00
Brandon Thomas 840733f760 Merge PR #1434 2026-10-13 11:48:32 -04:00
Brandon Thomas 7505017ea3 Merge PR #1432 2026-10-13 11:48:32 -04:00
Brandon Thomas 481272c1a3 Merge PR #1456 2026-10-13 11:48:31 -04:00
Michael Anderson 853a92e2de LR-EDIT-OPTICS-PROFILE: Apply Nikon's in-file distortion correction for the Z 6II and Z f
Read the polynomial, zoom and optical center offset from NikonNEFInfo.
2026-10-13 10:47:57 -05:00
Michael Anderson 271bc41842 Never crash: Repair damaged catalog snapshots on open
Fix ids, stacks, album folders and counters that don't agree, and stop album trees at 64 deep.
2026-10-13 10:46:47 -05:00
Michael Anderson 4a07254e06 Never crash: Read the edge pixel for bilinear samples outside the picture
Infinite coordinates from a zero-size crop overflowed the sampler.
2026-10-13 10:46:42 -05:00
Michael Anderson f753778f45 Never crash: Fix blur overflow and hangs on sliver crops
Cap the sigma at twice the picture's long side and square the box width in i64.
2026-10-13 10:46:37 -05:00
Michael Anderson 4c088c3284 Never crash: Give the loupe a finite aspect for a zero-size crop
Count each side as at least one pixel.
2026-10-13 10:46:33 -05:00
Michael Anderson b43b7169aa Never crash: Limit how much a preset zip can unpack
Cap entries at 256 MB and the archive at 1 GB, and check the offsets.
2026-10-13 10:46:28 -05:00
Michael Anderson 307843b2ad Never crash: Fix defringe radius overflow for amounts past the sliders
Treat the amount as the slider's end and keep the radius within the picture.
2026-10-13 10:46:23 -05:00
Michael Anderson e1860067a6 Never crash: Render demo scenes at most at their own size
A damaged width asked for a buffer size that overflowed.
2026-10-13 10:46:18 -05:00
Michael Anderson 911d7df321 Never crash: Fix panic dragging a tone curve point between close neighbours
Keep the point midway when there's no room, and keep curve points sorted.
2026-10-13 10:46:13 -05:00
Michael Anderson 57487875b5 Never crash: Cap the spot radius so huge spot sizes render (#1519)
Sidecar spot sizes aren't limited to the slider range. Count at most two long edges.
2026-10-13 10:45:50 -05:00
Michael Anderson f157654628 Never crash: Fix overflow on a BigTIFF IFD with a huge entry count (#1516)
Only read the next IFD offset when the whole table was read, with checked math.
2026-10-13 10:45:45 -05:00
Michael Anderson 27d1e9b04d Never crash: Cap enlarged exports at 512 MP and check memory first
A huge enlargement failed to allocate and took the app down.
2026-10-13 10:45:39 -05:00
Michael Anderson 337f02b4dc Never crash: Fix export size panic for very long strips
Apply the 1 px and 65535 px bounds in turn instead of clamping between bounds that can cross.
2026-10-13 10:45:34 -05:00
Michael Anderson 3624a50423 Never crash: Refuse raw data with two samples per pixel (#1511)
The demosaic read past each pixel. DNG decode and validation refuse it now.
2026-10-13 10:45:29 -05:00
Michael Anderson cef6f2d4b8 Never crash: Fix demosaic panic on a NaN black level
Read non-finite DNG black levels as 0 and map non-finite normalized samples to black.
2026-10-13 10:45:18 -05:00
Michael Anderson d5cba8b3dc xtask parity: Fail when docs/gaps.md lists a gap more than once
Also remove the duplicate rows for gaps 10, 22 and 23.
2026-10-13 10:45:11 -05:00
PiMinusMax d60fd328db Before/After button: Show Original (\) does not light it (fixes #1497)
The bottom bar's Before/After (Y) button was lit for every mode but Off,
so holding or toggling \ (Show Original) lit it too, and a click then
opened the side-by-side layout: one lit button for two different views.
It now stands only for the Before/After layouts.
2026-10-13 17:45:07 +02:00
Michael Anderson 65dbba9ecd LRC-LIB-CATALOG-IMPORT: Open catalogs whose tables are all still in the WAL
Accept schema format 0 in the main header and check page 1 again after the WAL overlay.
2026-10-13 10:45:06 -05:00
Michael Anderson b62b51378e Never crash: Fix i32 overflow in the NEF and PEF Huffman predictors (#1515)
Use checked adds and return a corrupt data error instead of panicking.
2026-10-13 10:45:00 -05:00
Michael Anderson e9d36451e6 Never crash: Fix overflow in mask.move with a huge delta
Saturate index + delta so any delta just moves the mask to the top or bottom.
2026-10-13 10:44:46 -05:00
Nathaniel LedfordandClaude Opus 5.5 2783e5de9b PERF-IMPORT: a scan, an import and a sidecar write no longer make a table of the whole library first
Setting up Synchronize Folder, an import review or an import made a table
of every photo in the library on the session's thread before the first
file was looked at, and naming one photo's sidecar worked out who owns
every shared sidecar name in the library. In a library of 3.5 million
photos (`scan_setup_bench`, release, a folder of 500): 5.8 s before a
Synchronize scan, 2.6 s before an import review, 1.7 s before an import,
2.4 s for each sidecar written.

- A scan keeps the library as it is (photos are shared, not copied) and
  looks up the files it finds (`library_files`) and the contents it probes
  (`library_hashes`) on its own thread.
- An import looks the library up with its first files
  (`ImportJob::look_up_library`), from the library and the options as they
  were when it was set up.
- The owners of shared sidecar names are worked out for the photos asked
  about (`StemOwners::among`): all five callers name only those photos.
- Synchronize Folder names its photos' sidecars in the scan, after it has
  seen that the folder is there.

Now 0.9 s, 0.05 s, 0.05 s and under 1 s. What is found, and how sidecars
are named, is unchanged: the tests hold the owners against the table of
the whole library as it was made before, and the key made without
allocating against the key, for every string of up to six of the
characters that decide it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:52:56 -04:00
MarekSpodBiedry 3946688f49 Reject RAW TIFFs with missing or undecodable image chunks (#1513) 2026-10-13 16:38:39 +02:00
MarekSpodBiedry d77d1a4650 MCP: do not resend a request whose reply was lost, report an unknown outcome (#1526) 2026-10-13 16:37:38 +02:00
MarekSpodBiedry 82da463f56 Refuse Open/New Library while an import is running (#1509) 2026-10-13 16:37:30 +02:00
MarekSpodBiedry 935fabeb18 Preview disk cache: ignore symlinked shard directories (#1504) 2026-10-13 16:36:39 +02:00
MarekSpodBiedry f865aa28c3 Reject a LeakyRelu alpha that is not a single float instead of reading it as 0 (#1530) 2026-10-13 16:36:37 +02:00
MarekSpodBiedry 8e26ba6c94 Check the SAM weight shape against the header before reading the tensor (#1529) 2026-10-13 16:36:24 +02:00
MarekSpodBiedry 51ce34d78c Reserve the name with an exclusive create when hard links are unavailable (#1503) 2026-10-13 16:36:09 +02:00
MarekSpodBiedry 4a91a1c218 Fetch: refuse a redirect from https to http (#1531) 2026-10-13 16:35:28 +02:00
MarekSpodBiedry 7d29dfac58 Recheck file contents before deleting a moved original (#1500) 2026-10-13 16:35:21 +02:00
MarekSpodBiedry df914dca41 Reject PEF streams that end before the pixels do instead of decoding zero padding (#1514) 2026-10-13 16:35:19 +02:00
notdezziandZCode 54321e6175 Packaging: confirm a successful uninstall with the completion dialog (fixes #892)
The MSI never included WiX's ExitDialog: the Publish events point at it, but only
a DialogRef pulls the UI extension's fragment (with its on-exit=success show) into
the package, so every successful run ended silently. Reference it, and note that
the shared completion page is the uninstall confirmation; packaging-lint now fails
when the reference is lost again.

🤖 Generated with [ZCode](https://zcode.ai)

Co-Authored-By: ZCode <[email protected]>
2026-10-13 16:15:46 +02:00
Navid Zaman Khan d5826e2386 fix(develop): reject malformed settings groups 2026-10-13 19:47:05 +06:00
eliets2 c38bf96ef9 Docs: Select Only Active Photo in the shortcut list and the parity checklist
Add the Cmd+Shift+D row to the library shortcut table with a short note on
what it does and where it can be rebound, add a checklist row for it with
status partial (covered by headless key tests; the native macOS menu path
is not verified), and update the checklist's Last updated line, revision
history and the summary counts that this one row changes.
2026-10-13 15:42:56 +03:00
dev-null-undefinedandClaude 14e0d12813 LR-IMP-PREVIEW-READ: stand-ins and raw thumbnails decode the smallest embedded JPEG that covers them
`embedded_preview_srgb` (the loupe stand-in and the grid thumbnail of an unedited raw) decoded the
largest embedded JPEG whatever the size asked for: a 512 px thumbnail of a Sony A7R VI ARW decoded
its 9984 x 6656 JPEG (5.6 MB) although the file holds a 1616 x 1080 one (0.3 MB).

`lightcraft_raw::embedded_preview_covering(bytes, min_edge)` returns the smallest of a TIFF-based
raw's JPEGs whose long edge is at least `min_edge` and that is a copy of the largest one: aspect
ratio within 1 % (not a letterboxed thumbnail of another shape) and the same number of components
(not the grey semantic masks of an iPhone ProRAW DNG); else the largest, as `embedded_preview`.
The colour-space handling stays in `decode_raw_preview` (a `covering` argument); an unsupported
raw's editing source and the camera-look fit keep the largest JPEG, so a source never depends on
its size level.

On 120 raws of every make in the corpus at 256 / 512 / 1600 px: wherever the largest JPEG is still
chosen the output is identical byte for byte; where a smaller one is (Sony, Nikon, Panasonic,
Adobe DNG) orientation matches, the size is equal or 1-2 px off, mean difference <= 4.7 / 255.

- Rebased onto #1020: its plan route (`preview_window` -> `planned_preview`) now takes the box size and picks
  with `embedded_preview_covering` too, so a TIFF raw read through the sparse plan gets the whole file's JPEG.

Co-Authored-By: Claude <[email protected]>
2026-10-13 14:33:52 +02:00
eliets2 fb47e4a185 Tests for Select Only Active Photo
Cover the chord in the photo grid, the square grid and the detail view
(selection narrowed, active photo kept, last edit undo step untouched), a
focused text field keeping the key, rebinding and unbinding in the keymap,
and the command refusing to run without an active photo.
2026-10-13 15:17:01 +03:00
eliets2 6f5e1f876e Edit: add Select Only Active Photo (Cmd+Shift+D)
A new library command narrows the selection to the active photo and keeps
that photo active. It is bound to Cmd+Shift+D (Ctrl+Shift+D on Windows and
Linux), listed in the Edit menu and the keyboard shortcut editor, and
disabled while there is no active photo. The binding does not collide with
any existing binding or alias.
2026-10-13 15:17:01 +03:00
dev-null-undefinedandClaude e7cd00ef56 M5.6: a zoomed view with Dehaze keeps its airlight proxy (each pan and slider step resampled the whole original)
A windowed render dehazes with the whole frame's airlight, estimated on a 384 px proxy of the frame
(`local::frame_airlight`). Every window asked for that proxy again, so every pan of a 1:1 view and every
slider step while zoomed sampled the whole original down to it on the CPU: ~200 ms of CPU on a 64 MP raw.

The proxy (taken before white balance) is now kept for the last 4 (source fingerprint, frame, size); white
balance, the dark channel and the percentile still run on it every time. Same pixels: the GPU window of
`render_bench` is byte-identical.

`optics::fingerprint` indexed the first pixel of an empty image; it now hashes only the dimensions then.

render_bench ONLY=window GPU_ONLY=1 on a 64 MP Sony ILCE-7RM6 raw (RTX 4070 Ti SUPER, 4-CPU quota), CPU time:
pan 208-212 -> 9-11 ms, exposure change 200-204 -> 4 ms, NR change 211-217 -> 13 ms.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:07:34 +02:00
dev-null-undefinedandClaude Opus 5.5 5bf455c456 LRC-LIB-CATALOG-IMPORT: an ARW6 is described from the whole file, not a partial read
header_plan skipped an ARW6's strip but its first 64 KB and then said Ready, while the ARW6
header probe (Mode::Header) validates every tile: its picture header, stream tables and the
first lines of its residual stream, which lie in the skipped bytes. The probe of the partly
read buffer then failed ("ARW6 first-half row count outside 2..=7") where the whole file
succeeds: sparse_corpus failed on arw6-sony-ilce7rm6-apsc-land-c.arw, the first ARW6 file it
reads (it stops at the first failure). Like NEF, ORF and PEF, whose header probes read
samples, an ARW6 now gets HeaderPlan::Whole from the header plan. The preview plan is
unchanged: the embedded preview reads no tile, and the 13 ARW previews in the corpus are
still read from 21 % of their bytes.

Only the test reads the header plan today (the app's partial reads use preview_plan), so no
import changes. New test: a synthetic two-tile ARW6 whose second tile lies past a first one
of bulk size (Ready before, Whole now). sparse_corpus passes on the 8 ILCE-7RM6 ARW6 files.

cargo test --workspace: 3205 passed, 1 failed, visualize::tests::
all_masks_overlay_shows_every_visible_mask_at_once, which fails on main b3d392b4 too
(PR #1456 addresses it); fmt, clippy, parity, layers, assets and wasm pass.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:45:40 +02:00
PiMinusMaxandClaude Opus 5.5 0d13a7d5d7 M5.6: a full-size raw source is no longer copied once more after the colour model
`fit` to the source's own size copies the whole picture (`resize` returns a clone). The raw path
called it unconditionally, so every full-size decode (an export, and its denoised twin) made a
second float RGB buffer: 288 MB and 160 ms at 24 MP, ~390 MB and 225 ms at 33 MP. The JPEG and
preview paths in the same file already fit only when the picture is larger than `max_edge`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:27:54 +02:00
radialmonster e82b5089f7 LR-RAW-DNG: DNGs written by older LightCraft keep their sensor-anchored CFAPattern reading; the writer now marks its spec-anchored files 'LightCraft DNG 2' 2026-10-13 03:44:44 -04:00
wstockandClaude Fable 5.1 5e9a1b6ec7 Visualize: the all-masks overlay shows the union of the masks, not the last one
`Overlay::AllMasks` painted each mask's plane in turn, and every mask view
rewrites the whole image from its alpha (White on Black writes `255·a`,
Image on Black multiplies by `a`…), so each plane wiped the ones before it
and only the last visible mask showed. The planes are now merged (per-pixel
max) and painted once; the GPU path goes through the same `apply`.

The test added with #919 could not pass: besides the above, its hidden-mask
check expected the plain pixel under White on Black, where everything
outside the masks is black by definition (as the single-mask test asserts).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
2026-10-13 07:26:18 +00:00
wstockandClaude Fable 5.1 e935ad75e2 LR-VIEW-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip select in Compare
In Compare a filmstrip click goes to `compare.set` (#747), so the grid's
modifier clicks never reached `library.select` and multi-select from the
filmstrip was impossible there (#187 follow-up). A ⌘/Ctrl-click now toggles
the photo and a Shift-click extends from the active photo, as in the grid;
the two photos shown stay selected and the active photo is unchanged, so a
rating key still acts on a photo that is on screen. A plain click still
replaces the active side.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
2026-10-13 07:22:42 +00:00
s17sh70x2jfl031agandClaude Opus 5.5 094686a2f6 i18n(pt-br): translate AI Denoise
The AI Denoise panel, model download and settings were still in English
in Brazilian Portuguese; this translates its 98 strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:11:58 -07:00
Marco Arnulfo 0d8e3e3913 LR-IMP-ADD-DIALOG: an import ends showing its first photo, in All Photos when the view doesn't hold it, unless another photo was chosen meanwhile (fixes #1446) 2026-10-13 09:04:10 +02:00
Marco Arnulfo c10ca4e958 LR-IMP-PREVIEW-READ: a raw whose embedded preview can't be read still gets a thumbnail (fixes #1448)
The grid queues no render behind an unedited raw's quick thumbnail, since the embedded JPEG is
meant to be the thumbnail. But whether that JPEG can be read is only known when the quick job
runs, and `quick_thumb_job` had no fallback: when the preview loader returned nothing the job
failed with "no quick preview", the failure was remembered for that key, and the cell stayed
empty until an edit changed it. The quick job now carries the real thumbnail job as its last
resort, as `quick_view_job` already does for the loupe.
2026-10-13 09:01:48 +02:00
smposaraandClaude Opus 5.5 26df1c5cdf LR-IMP-CAMERA-COVERAGE: verify Canon EOS RP CR3 pixel for pixel
Four CC0 EOS RP samples from raw.pixls.us (lossless RAW and C-RAW, full
frame and 1.6x crop mode) now have exact full-sensor regressions against
the DNGLab 0.8.0 black-box oracle (Windows binary, checked first against
the documented M50 RAW hash). The decoder already matched; no decoder
change was needed. Crop mode is a single 4032x2656 tile, full frame two
3192-wide tiles.

docs/cr3.md also records that the white level is Canon's specular white,
which at the -1/3-stop ISOs (e.g. 11062 at ISO 5000) is well below the
sensor's saturation point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 08:59:30 +02:00
s17sh70x2jfl031ag b73c0599fa i18n(pt-br): fix plurals and 6 inconsistent strings
Many pt-BR format strings dropped the English plural argument with
{:.0}, so nouns never became plural ("Exportada 5 foto"). Also fixes
the gender of "matiz" and two terms that disagreed with the catalog.
2026-10-12 23:41:18 -07:00
Jason Baldwin 6477d031fc LRC-LIB-PEOPLE: face pictures come from the embedded preview and are kept in the thumbnail cache (#1440)
An unedited raw's face tile is cut from its largest embedded preview, read
the planned way (header and JPEG, never the sensor data), when the preview
holds at least 3/4 of the tile's pixels across the face; otherwise, and for
edited photos and other files, it is developed as before. Tiles keep going
to the memory + on-disk thumbnail cache, now at 16 px size steps so the
thumbnail slider reuses them. The planned preview path carries an Adobe RGB
preview colour space and applies it instead of declining to the whole-file
read (Sony alpha 1 previews).
2026-10-13 08:31:23 +02:00
Brandon Thomas b3d392b499 Merge pull request #1438 from storytold/claude/integrate-batch-32
Integration batch 32-2: 7 reviewed contributor PRs
2026-10-13 02:18:34 -04:00
Arekatla Nishanth ChowdaryandClaude Opus 5.5 1462d96360 Contributors: add my people.toml entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:34:33 +05:30
Arekatla Nishanth ChowdaryandClaude Opus 5.5 edc3661680 Issue #1200: portable mode, a portable.txt beside the executable keeps settings in LightCraftData
As in PhotoCraft: when portable.txt (or LightCraft.portable) sits next to the
executable, config_dir() is <exe dir>/LightCraftData, so settings, logs and
models stay with a portable copy instead of %APPDATA%. LIGHTCRAFT_CONFIG_DIR
still wins and is used without probing beside the executable. A portable
folder that can't be written logs a warning and the platform folder is used.
The check runs once per process.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:34:33 +05:30
Claude 8bb084074c Merge storytold/lightcraft main into LR-MERGE-HDR (lens corrections)
Conflicts: crates/engine/src/tests_merge.rs (#1123's test landed where this branch's is; both kept),
docs/parity-checklist.md (the LR-MERGE-HDR row: both notes) and docs/whats-new.md (both entries).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 05:57:08 +00:00
Marco Arnulfo 8155c118cd LR-PREF-DISPLAY: test that the 1:1 window is drawn with the screen's profile (#1365) 2026-10-13 07:46:27 +02:00
Claudio 0a565d308a Recently Deleted shows deleted Local (browsed) photos (Fixes #1430)
A photo browsed in a Local folder is flagged deleted by photo.delete, but
Filter::matches_in dropped every browsed photo from views without a folder,
so it vanished from the folder and never reached Recently Deleted: it could
not be restored and sat in the catalog invisibly (a deleted record is also
never forgotten as an untouched Local one).

Deleted browsed photos now match the Recently Deleted filter, so they can be
restored; the library view still excludes browsed photos.
2026-10-12 23:15:38 -06:00
radialmonster 2d28ea81bd LR-SIDEBAR-LOCAL-SYNC: Synchronize Folder in the Local folder menu
A Local folder that holds library photos (or has a folder with some inside)
now offers Synchronize Folder... like the Folders section. The check reads
the cached Folders tree only. Headless test covers shown and not shown.
2026-10-13 00:37:53 -04:00
sanyamsingh001andCodex 056b0f7a28 LR-MASK-OBJECTS: open the configured model folder and preserve retry
Open the configured SAM directory itself and preserve the pending mask
action and existing files when folder creation or the opener fails.
Run the Linux desktop opener in a background task and deliver failures
to the UI for retry. Directory creation remains synchronous.

Cover configured paths, creation and opener failures, retry state and
worker completion.

Partially addresses #366; model download hosting remains unresolved.
Credit: sanyamsingh001

Co-authored-by: Codex <[email protected]>
2026-10-13 04:22:23 +00:00
Claude 4c1dfc5b0f LR-MERGE-HDR: keep the reference frame's lens corrections in HDR merges
Merged HDR DNGs lost the camera's embedded lens correction: load_frame strips the lens opcodes from each
source (frames are merged uncorrected) and write_linear_dng wrote no OpcodeList3, so Optics ▸ Enable Lens
Corrections did nothing on the result (Sony ILCE-7M3/7RM4A, Panasonic/Leica, DNGs with their own opcodes).

Each raw frame now keeps the lens corrections it applies (the DNG 1.6 skip rule already applied),
re-expressed for the default crop develop keeps by the new Opcode::cropped: the centre moves into the crop
and coefficients are rescaled for the crop's farthest corner. HdrResult carries the reference frame's and the
DNG writes them as its OpcodeList3. Frames turned before merging (mixed orientations) and panoramas carry none.

Tests: cropped corrections give the crop of the corrected whole (WarpRectilinear, WarpRectilinear2 with and
without reciprocal, FixVignetteRadial); an HDR merge of cropped, corrected brackets, upright and vertical, gives
the same profile correction as its reference photo (fails without the fix). Real brackets not measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 01:05:26 +00:00
dev-null-undefinedandClaude 1c70b24c74 M5.7: perf harnesses for slider latency, zoom windows and photo loading (examples only)
- drag_latency (ui-egui example): the whole app driven headless at 60 frames a second; per develop control,
  the latency of each drag tick until its draft is the loupe (or, with ZOOM, the zoom window) texture, the
  ticks never shown, the UI thread's frame time and the time to the full-quality render after release.
- drag_tick_bench (engine example): the UI-thread work of one drag tick outside the render.
- render_bench: ONLY=window (a 1:1 zoom window cut from the original: first window, pan, slider changes;
  GPU_ONLY=1, DUMP=file for pixel A/B), ONLY=load / load1 / jpeg (+ LOADONLY=1): the steps of opening a photo.
  The window rows carry no colon in their names, so `cargo xtask bench` records each of them (parser test).
- open_timeline (ui-egui example): opening raws in Detail through the real app logic, headless with the GPU.

Co-Authored-By: Claude <[email protected]>
2026-10-13 02:21:46 +02:00
Chad ElliottandClaude Opus 5.5 929202b95f docs: #1046 document the cancelled flags and that the UI decides on error kinds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:04 -05:00
Chad ElliottandClaude Opus 5.5 b3fcb77922 fix(ui-egui): #1046 decide on error kinds and flags, not message text
LightcraftApp::run_typed keeps the engine's ErrorKind in a CommandError;
run() is run_typed(..).map_err(|e| e.message), so its callers are
untouched. The face detector offer and the SAM 3 download dialog decide
on ModelMissing, the SAM 3 download toast and the Apple Photos note on
the `cancelled` flag, and a Lightroom inspect/import on its own cancel
flag only, so a failure that mentions "cancelled" is still a failure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:04 -05:00
Chad ElliottandClaude Opus 5.5 e4c36749ef feat(engine): #1046 typed error kinds for cancelled work and missing models
EngineError gains Cancelled and ModelMissing { model: AiModel } and a
kind() -> ErrorKind, so frontends decide on what an error is rather than
on its words. The face detector, SAM 3 model checks and the Lightroom
job produce them at the source; the error text is unchanged, so control
channel and MCP clients see the same `error` strings.

Background results that cross the wire as JSON get an additive flag
instead: SAM 3 download status `cancelled`, Apple Photos import outcome
`cancelled: true` next to `skipped`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:04 -05:00
152 changed files with 6788 additions and 733 deletions

No files matched your search

+5
View File
@@ -58,6 +58,11 @@ jobs:
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
done
# The completion page the Publish events point at must actually exist: a Publish alone
# does not pull the dialog fragment into the MSI, and without the DialogRef the
# installer finishes silently (issues #790, #892).
grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \
|| { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
+2 -1
View File
@@ -16,7 +16,8 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `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.
path the tracker cites still exists and that `docs/gaps.md` lists each gap once, 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
+31 -31
View File
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -3097,7 +3097,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3130,7 +3130,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3159,7 +3159,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3168,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3185,7 +3185,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3200,7 +3200,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3212,7 +3212,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"ab_glyph",
"harfrust",
@@ -3252,7 +3252,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3266,7 +3266,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"log",
"rustls",
@@ -3277,7 +3277,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3285,7 +3285,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3293,7 +3293,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3311,7 +3311,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3319,14 +3319,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3335,7 +3335,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3346,7 +3346,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3362,7 +3362,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3374,7 +3374,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3392,7 +3392,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3404,7 +3404,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3416,7 +3416,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3438,7 +3438,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3449,7 +3449,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3462,11 +3462,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.7.1"
version = "0.8.0"
[[package]]
name = "lightcraft-tiff"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3475,7 +3475,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
@@ -3502,7 +3502,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"brotli",
"flate2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+29 -2
View File
@@ -337,7 +337,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| 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, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), 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), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
@@ -370,6 +370,31 @@ per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged.
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
override these defaults. Ordinary development commands keep their own settings.
**Release builds on memory-constrained machines:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
reports Arch/AUR release-link OOM kills on a 16 GB machine when the desktop and CLI build concurrently
with full debug information, thin LTO and one codegen unit. The CI defaults above do not apply to
ordinary `cargo build --release`. Start with one job; two may be suitable if enough memory is available:
```sh
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
# Alternatively: CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
```
If a packaging environment enables full debug information or memory-heavy LTO settings, use
command-local overrides for a lower-memory source build:
```sh
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
```
These POSIX-shell assignments affect only this command, not global Cargo configuration or the
repository's release profiles. Disabling LTO may change performance and binary size; disabling debug
information reduces debugging detail. This is build guidance, not a guarantee that any machine has
enough memory. Downstream packagers should serialize large binary builds and avoid full debug
information combined with LTO and one codegen unit on low-memory builders. A PKGBUILD that exports
its own profile settings can override inherited variables; adjust the packaging recipe locally or
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
```sh
@@ -385,7 +410,9 @@ Language**; the choice applies immediately and persists. See [docs/localization.
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library.
**Portable mode:** a file named `portable.txt` (or `LightCraft.portable`) next to the executable moves the settings
folder to `LightCraftData` beside it. A
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
Folder** shows it in the file manager). Each start moves the
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
+24
View File
@@ -359,6 +359,30 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
**内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
报告在 16 GB 的 Arch/AUR 机器上,同时构建桌面应用和 CLI,并启用完整调试信息、thin LTO
和单个代码生成单元时,链接过程被 OOM 终止。上述 CI 默认设置不适用于普通的
`cargo build --release`。建议先使用一个构建任务;内存充足时可以尝试两个:
```sh
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
```
如果打包环境启用了完整调试信息或占用较多内存的 LTO 设置,可以使用仅对本条命令生效的
覆盖设置,降低源码构建的内存需求:
```sh
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
```
这些 POSIX shell 赋值只影响本条命令,不修改全局 Cargo 配置或仓库的 release profile。
关闭 LTO 可能改变性能和二进制大小;关闭调试信息会减少可用的调试细节。这是构建建议,
并不保证任何机器都有足够内存。下游打包者应串行构建大型二进制文件,并避免在低内存
构建机器上同时使用完整调试信息、LTO 和单个代码生成单元。PKGBUILD 自己导出的 profile
设置可以覆盖继承的环境变量;应在本地调整打包配方或联系维护者,而不要假定给
`makepkg` 添加环境变量前缀就能优先生效。
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
```sh
+10 -1
View File
@@ -462,6 +462,14 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
pick_folder: Some(Box::new(|| {
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
#[cfg(target_os = "linux")]
open_model_folder: Some(std::sync::Arc::new(|path: &str| {
std::process::Command::new("xdg-open")
.arg(path)
.status()
.map_err(|e| e.to_string())
.and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) })
})),
open_with: Some(Box::new(editors::open_with)),
open_all_with: Some(Box::new(editors::open_all_with)),
check_editor: Some(Box::new(editors::check)),
@@ -666,7 +674,8 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder).
Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
+4
View File
@@ -27,3 +27,7 @@ sync_github_name = true
[people.sanyamsingh001]
display_name = "sanyamsingh001"
[people.ArekatlaNishanthchowdary]
real_name = "Arekatla Nishanth Chowdary"
sync_github_name = true
+8 -1
View File
@@ -113,6 +113,8 @@ pub struct LoadReport {
/// The library was in an older catalog format (`0` = before format versions: a log without a
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
pub upgraded_from: Option<u32>,
/// What [`Catalog::repair`] changed in a damaged snapshot (empty for a sound one).
pub repaired: Vec<String>,
}
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
@@ -328,7 +330,12 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
(s.catalog, s.seq, Some(h.version))
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
log::warn!("{SNAPSHOT}: {r}");
}
(catalog, s.seq, Some(h.version))
}
None => (Catalog::new(), 0, None),
};
+102 -9
View File
@@ -290,19 +290,21 @@ impl Catalog {
// ---- ids
// (saturating: a counter a damaged snapshot left at the top hands out its last id again, which
// the op that adds it then refuses as taken, rather than overflowing)
pub fn alloc_photo_id(&mut self) -> PhotoId {
let id = PhotoId(self.next_photo.max(1));
self.next_photo = id.0 + 1;
self.next_photo = id.0.saturating_add(1);
id
}
pub fn alloc_album_id(&mut self) -> AlbumId {
let id = AlbumId(self.next_album.max(1));
self.next_album = id.0 + 1;
self.next_album = id.0.saturating_add(1);
id
}
pub fn alloc_stack_id(&mut self) -> StackId {
let id = StackId(self.next_stack.max(1));
self.next_stack = id.0 + 1;
self.next_stack = id.0.saturating_add(1);
id
}
@@ -497,7 +499,7 @@ impl Catalog {
return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id)));
}
let id = photo.id;
self.next_photo = self.next_photo.max(id.0 + 1);
self.next_photo = self.next_photo.max(id.0.saturating_add(1));
self.photos.insert(id, Arc::new(*photo));
Op::RemovePhoto { id }
}
@@ -587,7 +589,7 @@ impl Catalog {
self.validate_rules(rules)?;
}
let id = album.id;
self.next_album = self.next_album.max(id.0 + 1);
self.next_album = self.next_album.max(id.0.saturating_add(1));
self.albums.insert(id, album);
Op::RemoveAlbum { id }
}
@@ -607,13 +609,15 @@ impl Catalog {
if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) {
return Err(CatalogError::Invalid("parent must be another folder".into()));
}
// no cycles
// no cycles (bounded: a loop elsewhere in a damaged catalog must not hang this)
let mut cur = Some(p);
let mut steps = 0usize;
while let Some(c) = cur {
if c == id {
if c == id || steps > self.albums.len() {
return Err(CatalogError::Invalid("cannot move a folder into itself".into()));
}
cur = self.albums.get(&c).and_then(|a| a.parent);
steps += 1;
}
}
let a = self.album_mut(id)?;
@@ -659,7 +663,7 @@ impl Catalog {
}
self.validate_stack(stack.id, &stack.photos)?;
let id = stack.id;
self.next_stack = self.next_stack.max(id.0 + 1);
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
self.stacks.insert(id, stack);
Op::RemoveStack { id }
}
@@ -823,7 +827,94 @@ impl Catalog {
}
pub fn from_snapshot(s: &str) -> Result<Catalog> {
serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))
let mut c: Catalog = serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))?;
c.repair();
Ok(c)
}
/// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left
/// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it
/// finds in them, and walks folders up to the top): every photo, album and stack carries the
/// id it is filed under; the id counters lie past every id in use; a stack keeps only photos
/// in the catalog, once each, and goes when fewer than two are left; an album whose parent is
/// missing, not a folder or inside itself moves to the top level. Returns what was changed
/// (nothing, for a catalog this build wrote).
pub fn repair(&mut self) -> Vec<String> {
let mut changed = Vec::new();
for (id, p) in self.photos.iter_mut() {
if p.id != *id {
changed.push(format!("photo {} was filed as {}", p.id.0, id.0));
Arc::make_mut(p).id = *id;
}
}
for (id, a) in self.albums.iter_mut() {
if a.id != *id {
changed.push(format!("album {} was filed as {}", a.id.0, id.0));
a.id = *id;
}
}
let photos = &self.photos;
self.stacks.retain(|id, st| {
if st.id != *id {
changed.push(format!("stack {} was filed as {}", st.id.0, id.0));
st.id = *id;
}
let mut seen = std::collections::HashSet::new();
let before = st.photos.len();
st.photos.retain(|p| photos.contains_key(p) && seen.insert(*p));
if st.photos.len() != before {
changed.push(format!("stack {}: {} missing or repeated photo(s) left out", id.0, before - st.photos.len()));
}
let keep = st.photos.len() >= 2;
if !keep {
changed.push(format!("stack {} had fewer than two photos: removed", id.0));
}
keep
});
// a photo in two stacks stays in the first
let mut stacked = std::collections::HashSet::new();
self.stacks.retain(|id, st| {
let before = st.photos.len();
st.photos.retain(|p| stacked.insert(*p));
if st.photos.len() != before {
changed.push(format!("stack {}: {} photo(s) already in another stack left out", id.0, before - st.photos.len()));
}
st.photos.len() >= 2
});
let parents: Vec<(AlbumId, Option<AlbumId>)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
for (id, parent) in parents {
let Some(p) = parent else { continue };
let mut cur = Some(p);
let mut steps = 0usize;
let ok = loop {
match cur {
None => break true,
Some(c) if c == id || steps > self.albums.len() => break false,
Some(c) => match self.albums.get(&c) {
Some(a) if a.folder => cur = a.parent,
_ => break false,
},
}
steps += 1;
};
if !ok && let Some(a) = self.albums.get_mut(&id) {
changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
a.parent = None;
}
}
let past = |last: Option<u64>| last.map_or(1, |v| v.saturating_add(1));
let (photo, album, stack) = (
past(self.photos.keys().last().map(|k| k.0)),
past(self.albums.keys().last().map(|k| k.0)),
past(self.stacks.keys().last().map(|k| k.0)),
);
if self.next_photo < photo || self.next_album < album || self.next_stack < stack {
changed.push("id counters behind the ids in use: moved past them".into());
self.next_photo = self.next_photo.max(photo);
self.next_album = self.next_album.max(album);
self.next_stack = self.next_stack.max(stack);
}
changed
}
/// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored.
@@ -878,4 +969,6 @@ mod tests_local;
#[cfg(test)]
mod tests_lock;
#[cfg(test)]
mod tests_repair;
#[cfg(test)]
mod tests_torn_append;
+2 -2
View File
@@ -253,8 +253,8 @@ impl Filter {
return false;
}
match &self.folder {
// browsed photos only show in folder views
None if p.local => return false,
// browsed photos only show in folder views, and in Recently Deleted once deleted (#1430)
None if p.local && !p.deleted => return false,
None => {}
Some(dir) => {
let crate::Source::File { path } = &p.source else { return false };
+33 -9
View File
@@ -20,8 +20,8 @@
//! it is then simply exported again.
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
//! and sync, plus a read-back check, and the file then appears under its final name **without
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
//! volume has no hard links).
//! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard
//! links, an exclusive create that claims the name, then a rename over that placeholder).
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
//! resolved paths elsewhere), for "is this target an original?" checks.
@@ -226,16 +226,24 @@ fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
match fs::hard_link(tmp, path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
Err(_) => {
if fs::symlink_metadata(path).is_ok() {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
}
fs::rename(tmp, path)
}
// no hard links on this volume (FAT/exFAT, some shares)
Err(_) => publish_new_by_rename(tmp, path, &|| {}),
}
}
/// Fallback of [`publish_new`]: reserve the name with an exclusive create (atomic, so a file that
/// appears at the same time is never replaced), then rename the complete temp file over our own
/// empty placeholder. `before_claim` runs just before the name is claimed (a test hook).
fn publish_new_by_rename(tmp: &Path, path: &Path, before_claim: &dyn Fn()) -> io::Result<()> {
before_claim();
OpenOptions::new().write(true).create_new(true).open(path)?;
if let Err(e) = fs::rename(tmp, path) {
let _ = fs::remove_file(path);
return Err(e);
}
Ok(())
}
/// Does the file at `path` hold exactly `data`?
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
use std::io::Read;
@@ -296,6 +304,22 @@ mod tests {
v
}
#[test]
fn fallback_publish_never_replaces_a_file_that_appears_at_the_same_time() {
let d = temp("nolink-race");
let tmp = d.join(".tmp-new");
let dest = d.join("IMG_1.dng");
fs::write(&tmp, b"new").unwrap();
let e = publish_new_by_rename(&tmp, &dest, &|| fs::write(&dest, b"other writer").unwrap()).unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&dest).unwrap(), b"other writer");
fs::remove_file(&dest).unwrap();
publish_new_by_rename(&tmp, &dest, &|| {}).unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"new");
assert!(!tmp.exists());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn atomic_write_replaces_and_leaves_no_temp() {
let d = temp("replace");
+6 -3
View File
@@ -25,7 +25,7 @@ impl Catalog {
}
/// Photo → (stack, position in the stack), for every stacked photo.
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect()
self.stacks.iter().flat_map(|(id, s)| s.photos.iter().enumerate().map(move |(i, p)| (*p, (*id, i)))).collect()
}
pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> {
@@ -70,7 +70,7 @@ impl Catalog {
}
}
for sid in &touched {
for p in self.stacks[sid].photos.clone() {
for p in self.stacks.get(sid).map(|s| s.photos.clone()).unwrap_or_default() {
push(&mut order, p);
}
}
@@ -186,7 +186,10 @@ impl Catalog {
if !done.insert(*sid) {
continue;
}
let s = &self.stacks[sid];
let Some(s) = self.stacks.get(sid) else {
out.push(*id);
continue;
};
let members = s.photos.iter().copied().filter(|p| present.contains(p));
if s.collapsed {
out.extend(members.take(1));
+17
View File
@@ -237,6 +237,23 @@ fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
assert!(!f.matches(c.photo(kept).unwrap(), &c));
}
#[test]
fn a_deleted_browsed_photo_shows_in_recently_deleted_and_comes_back() {
let mut c = Catalog::new();
let browsed = add_with(&mut c, "/pics/trip/a.jpg", |p| p.local = true);
let recently_deleted = Filter { deleted: true, ..Default::default() };
let library = Filter::default();
assert!(!recently_deleted.matches(c.photo(browsed).unwrap(), &c));
assert!(!library.matches(c.photo(browsed).unwrap(), &c), "browsed photos stay out of the library view");
c.apply(Op::SetDeleted { id: browsed, deleted: true }).unwrap();
assert_eq!(c.query(&recently_deleted, &Sort::default()), vec![browsed], "#1430: it was invisible everywhere");
assert!(c.query(&library, &Sort::default()).is_empty());
c.apply(Op::SetDeleted { id: browsed, deleted: false }).unwrap();
assert!(c.query(&recently_deleted, &Sort::default()).is_empty());
}
#[test]
fn an_empty_folder_choice_is_no_choice() {
let c = library(&["/pics/a.jpg"]);
+152
View File
@@ -0,0 +1,152 @@
//! A damaged or hand-edited snapshot that still parses is made to hold together on load
//! ([`Catalog::repair`]): found by fuzzing snapshots of a real library.
use crate::journal::SNAPSHOT;
use crate::*;
fn album(id: u64, parent: Option<u64>, folder: bool, photos: Vec<PhotoId>) -> Album {
Album {
id: AlbumId(id),
name: format!("A{id}"),
parent: parent.map(AlbumId),
folder,
photos,
cover: None,
smart: None,
quick: false,
order: None,
}
}
/// A catalog of four photos, a stack of two, a folder with an album in it.
fn sound() -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for i in 0..4 {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{i}.jpg") }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01")),
})
.unwrap();
ids.push(id);
}
let sid = c.alloc_stack_id();
c.apply(Op::AddStack { stack: Stack { id: sid, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap();
let (f, a) = (c.alloc_album_id(), c.alloc_album_id());
c.apply(Op::AddAlbum { album: album(f.0, None, true, vec![]) }).unwrap();
c.apply(Op::AddAlbum { album: album(a.0, Some(f.0), false, vec![ids[2]]) }).unwrap();
(c, ids)
}
/// The snapshot file of `c` with `edit` applied to its JSON, loaded through the journal.
fn load_edited(c: &Catalog, edit: impl FnOnce(&mut serde_json::Value)) -> (Catalog, LoadReport) {
let m = MemStore::new();
let (mut j, _, _) = Journal::open(Box::new(m.clone())).unwrap();
j.snapshot(c).unwrap();
let mut v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
edit(&mut v["catalog"]);
m.set(SNAPSHOT, serde_json::to_vec(&v).unwrap());
drop(j);
let (_, c, report) = Journal::open(Box::new(m)).unwrap();
(c, report)
}
/// A stack whose `id` differs from the key it is filed under made `arrange_stacks` index the map
/// with an id it doesn't hold, which panics: the grid of any library with such a stack.
#[test]
fn a_stack_filed_under_another_id_is_arranged() {
let (c, ids) = sound();
let (c, report) = load_edited(&c, |v| {
let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
v["stacks"][&key]["id"] = 77.into();
});
assert!(report.repaired.iter().any(|r| r.contains("stack 77 was filed as")), "{:?}", report.repaired);
assert_eq!(c.arrange_stacks(&ids), vec![ids[0], ids[2], ids[3]], "the collapsed stack shows its top");
assert!(c.stacks().all(|s| c.stack_of(s.photos[0]).is_some_and(|t| t.id == s.id)));
}
/// Stacks of missing, repeated or too few photos (or one photo in two stacks) are put right: a
/// stack of none had no top (`photos[0]`).
#[test]
fn stacks_keep_only_photos_in_the_catalog() {
let (c, ids) = sound();
let (c, report) = load_edited(&c, |v| {
let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
v["stacks"][&key]["photos"] = serde_json::json!([ids[0].0, 999, ids[0].0, ids[1].0]);
v["stacks"]["8"] = serde_json::json!({"id": 8, "photos": []});
v["stacks"]["9"] = serde_json::json!({"id": 9, "photos": [ids[1].0, ids[3].0, ids[2].0]});
});
let stacks: Vec<(StackId, Vec<PhotoId>)> = c.stacks().map(|s| (s.id, s.photos.clone())).collect();
assert_eq!(stacks.len(), 2, "{stacks:?} {:?}", report.repaired);
assert_eq!(stacks[0].1, vec![ids[0], ids[1]], "missing and repeated photos left out");
assert_eq!(stacks[1].1, vec![ids[3], ids[2]], "a photo already in a stack stays in the first");
assert!(!c.arrange_stacks(&ids).is_empty());
}
/// Folders inside each other (a loop), or under an album or a missing folder, move to the top
/// level: walking up from them never ends otherwise, and moving any folder there hung.
#[test]
fn folder_loops_and_missing_parents_move_to_the_top() {
let (c, _) = sound();
let (mut c, report) = load_edited(&c, |v| {
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), true, vec![])).unwrap();
v["albums"]["11"] = serde_json::to_value(album(11, Some(10), true, vec![])).unwrap();
v["albums"]["12"] = serde_json::to_value(album(12, Some(12), true, vec![])).unwrap();
v["albums"]["13"] = serde_json::to_value(album(13, Some(404), false, vec![])).unwrap();
});
assert!(!report.repaired.is_empty());
for id in [10, 11, 12, 13] {
let a = c.album(AlbumId(id)).unwrap();
assert!(
a.parent.is_none() || a.parent.is_some_and(|p| c.album(p).is_some_and(|f| f.folder && f.parent.is_none())),
"album {id}: {:?}",
a.parent
);
}
// moving a folder around terminates either way
let _ = c.apply(Op::MoveAlbum { id: AlbumId(10), parent: Some(AlbumId(11)) });
let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) });
}
/// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer
/// overflows (a panic in debug builds) and never hands out an id already in use.
#[test]
fn id_counters_are_moved_past_the_ids_in_use() {
let (c, ids) = sound();
let (mut c, report) = load_edited(&c, |v| {
v["next_photo"] = 1.into();
v["next_album"] = 1.into();
v["next_stack"] = 1.into();
});
assert!(report.repaired.iter().any(|r| r.contains("id counters")), "{:?}", report.repaired);
assert!(c.alloc_photo_id().0 > ids[3].0);
let (mut c, _) = load_edited(&c, |v| v["next_photo"] = u64::MAX.into());
assert_eq!((c.alloc_photo_id().0, c.alloc_photo_id().0), (u64::MAX, u64::MAX), "saturates");
let p = Photo::new(PhotoId(u64::MAX), Source::Demo { scene: 1 }, "a", "JPEG", 1, 1, "t");
c.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
assert!(c.apply(Op::AddPhoto { photo: Box::new(p) }).is_err(), "the id is taken: refused, not overwritten");
}
/// A sound catalog loads unchanged.
#[test]
fn a_sound_snapshot_needs_no_repair() {
let (c, _) = sound();
let (loaded, report) = load_edited(&c, |_| {});
assert!(report.repaired.is_empty(), "{:?}", report.repaired);
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
}
/// Found by fuzzing: an album filed under one id but naming another (here: the folder's) put
/// itself inside itself, and listing the folder tree overflowed the stack (an abort, which no
/// panic guard catches, every time the library opened).
#[test]
fn an_album_filed_under_another_id_gets_its_own() {
let (c, _) = sound();
let folder = c.albums().find(|a| a.folder).unwrap().id;
let inner = c.albums().find(|a| !a.folder).unwrap().id;
let (c, report) = load_edited(&c, |v| v["albums"][inner.0.to_string()]["id"] = folder.0.into());
assert!(report.repaired.iter().any(|r| r.contains(&format!("album {} was filed as {}", folder.0, inner.0))), "{:?}", report.repaired);
let a = c.album(inner).unwrap();
assert_eq!((a.id, a.parent), (inner, Some(folder)));
assert_eq!(c.album_children(Some(folder)).iter().map(|a| a.id).collect::<Vec<_>>(), vec![inner]);
}
+5
View File
@@ -5,3 +5,8 @@ No weights or inference backend. See [the user workflow](../../docs/denoise.md).
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
crate directly when inference is disabled. The default download catalog is empty.
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.
The Bayer runner rejects non-finite source samples before inference and non-finite
results after gain matching, highlight protection and tile blending. Finite negative
and HDR source samples remain supported. These guards do not implement Raw Details
or Super Resolution.
+56
View File
@@ -95,6 +95,9 @@ pub fn denoise_bayer(
if mosaic.len() != pixels {
return Err(Error::Input("the mosaic does not have width × height samples".into()));
}
if mosaic.iter().any(|v| !v.is_finite()) {
return Err(Error::Input("the mosaic has samples that are not finite".into()));
}
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
return Err(Error::Input("the tile size and overlap do not fit".into()));
}
@@ -148,6 +151,9 @@ pub fn denoise_bayer(
return Err(Error::Model("numbers that are not finite".into()));
}
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
if out.iter().any(|v| !v.is_finite()) {
return Err(Error::Model("numbers that are not finite after scale matching or highlight protection".into()));
}
Ok(out)
})
.collect()
@@ -201,6 +207,9 @@ pub fn denoise_bayer(
}
}
});
if acc.iter().flatten().any(|v| !v.is_finite()) {
return Err(Error::Model("numbers that are not finite after tile blending".into()));
}
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!(
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
@@ -388,6 +397,53 @@ mod tests {
Params { tile, overlap, gain, clip: None, parallel: 2 }
}
#[test]
fn nonfinite_mosaics_are_rejected_before_inference() {
struct Counting(std::sync::atomic::AtomicUsize);
impl TileRunner for Counting {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(vec![0.0; 3 * 32 * 32])
}
}
let model = Counting(std::sync::atomic::AtomicUsize::new(0));
let layout = Layout { dx: 0, dy: 0 };
for sample in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(denoise_bayer(&[sample], 1, 1, layout, &model, &params(16, 0, Gain::None), &Control::default()), Err(Error::Input(_))));
}
assert_eq!(model.0.load(Ordering::Relaxed), 0);
// Finite scene-linear samples can be negative or exceed SDR white.
for sample in [-0.25, 2.0] {
assert!(denoise_bayer(&[sample], 1, 1, layout, &model, &params(16, 0, Gain::None), &Control::default()).is_ok());
}
}
#[test]
fn finite_model_output_that_overflows_during_gain_matching_is_rejected() {
struct Extreme;
impl TileRunner for Extreme {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
Ok(vec![f32::MAX; 3 * 32 * 32])
}
}
let p = params(16, 0, Gain::MatchMean { nominal: 1e-6, max_deviation: 0.0 });
assert!(matches!(denoise_bayer(&[0.5], 1, 1, Layout { dx: 0, dy: 0 }, &Extreme, &p, &Control::default()), Err(Error::Model(_))));
}
#[test]
fn overlapping_finite_tiles_that_overflow_the_accumulator_are_rejected() {
struct Extreme;
impl TileRunner for Extreme {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
Ok(vec![f32::MAX; 3 * 32 * 32])
}
}
assert!(matches!(
denoise_bayer(&vec![0.5; 48 * 48], 48, 48, Layout { dx: 0, dy: 0 }, &Extreme, &params(16, 4, Gain::None), &Control::default()),
Err(Error::Model(_))
));
}
#[test]
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
+25 -1
View File
@@ -97,7 +97,12 @@ fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
return invalid("malformed LeakyRelu");
}
let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
// only an omitted alpha gets the ONNX default; one that is not a single float must not read as 0
let alpha = match next.attribute.as_slice() {
[] => 0.01,
[AttributeProto { f: Some(f), kind: 0 | 1, .. }] => *f,
_ => return invalid("LeakyRelu alpha is not a single float"),
};
i += 1;
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
return Ok((Some(alpha), name));
@@ -319,6 +324,25 @@ mod tests {
let _ = std::fs::remove_file(p);
}
#[test]
fn a_leaky_relu_alpha_that_is_not_a_float_is_rejected() {
let good = unet_onnx(4, 3, 1, 7);
assert!(read_bytes(&good).is_ok());
// an alpha attribute: float field 2, then the declared type (field 20) FLOAT
let mut alpha = vec![0x15];
alpha.extend_from_slice(&0.2f32.to_le_bytes());
let at = good.windows(alpha.len()).position(|w| w == alpha.as_slice()).unwrap() + alpha.len();
assert_eq!(good[at..at + 3], [0xa0, 0x01, 1]);
// declared INT: the float must not be taken as the slope
let mut wrong_type = good.clone();
wrong_type[at + 2] = 2;
assert!(matches!(read_bytes(&wrong_type), Err(NetError::Invalid(_))));
// declared FLOAT but no float value (the float field becomes an int field 3 = 0 of the same length): must not read as 0
let mut no_float = good.clone();
no_float[at - 5..at].copy_from_slice(&[0x18, 0x80, 0x80, 0x80, 0x00]);
assert!(matches!(read_bytes(&no_float), Err(NetError::Invalid(_))));
}
#[test]
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
+6 -3
View File
@@ -113,7 +113,10 @@ fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
#[derive(Default)]
pub(super) struct AttributeProto {
pub name: String,
pub f: f32,
/// `None` when the file has no float value for the attribute.
pub f: Option<f32>,
/// The declared attribute type (1 float, 2 int, 3 string, 7 ints); 0 when the file does not say.
pub kind: i64,
pub i: i64,
pub s: Vec<u8>,
pub ints: Vec<i64>,
@@ -151,7 +154,7 @@ fn attribute(b: &[u8]) -> Result<AttributeProto> {
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => a.name = string(b)?,
(2, Wire::F32(v)) => a.f = v,
(2, Wire::F32(v)) if a.f.is_none() => a.f = Some(v),
(3, Wire::Int(v)) => a.i = v as i64,
(4, Wire::Bytes(b)) => {
if b.len() > MAX_STRING {
@@ -160,7 +163,7 @@ fn attribute(b: &[u8]) -> Result<AttributeProto> {
a.s = b.to_vec();
}
(8, w) => ints(w, &mut a.ints, 16)?,
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
(20, Wire::Int(v @ (1 | 2 | 3 | 7))) if a.kind == 0 => a.kind = v as i64,
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
}
}
+42
View File
@@ -0,0 +1,42 @@
//! UI-thread work one slider-drag tick costs outside the render: `develop.set` inside an
//! interaction, the filmstrip's `visible()` after the catalog revision moved, and the settings
//! hash the loupe takes every frame. `cargo run --release -p lightcraft-engine --example
//! drag_tick_bench -- [photos]` (default 20000).
#![allow(clippy::unwrap_used, clippy::expect_used)]
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use lightcraft_engine::Session;
use serde_json::json;
use std::time::Instant;
fn main() {
let n: u64 = std::env::args().nth(1).and_then(|a| a.parse().ok()).unwrap_or(20_000);
let mut s = Session::new();
for i in 1..=n {
let mut p = Photo::new(PhotoId(i), Source::File { path: format!("/x/{i}.ARW") }, &format!("{i}.ARW"), "ARW", 9984, 6656, "2026-01-01");
p.captured = Some(format!("2025-{:02}-{:02}T10:{:02}:00", 1 + i % 12, 1 + i % 28, i % 60));
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
s.execute("library.select", &json!({"ids": [1]})).unwrap();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
s.visible_cloned();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
let ticks = 30;
let (mut set, mut vis, mut hash) = (0.0, 0.0, 0.0);
for k in 0..ticks {
let t = Instant::now();
s.execute("develop.set", &json!({"control": "light.exposure", "value": k as f64 / 30.0})).unwrap();
set += ms(t);
let t = Instant::now();
let v = s.visible_cloned();
vis += ms(t);
assert_eq!(v.len() as u64, n);
let d = s.develop_of(PhotoId(1)).unwrap();
let t = Instant::now();
std::hint::black_box(d.hash64());
hash += ms(t);
}
s.execute("develop.endInteraction", &json!({})).unwrap();
let f = ticks as f64;
println!("{n} photos, per tick: develop.set {:.3} ms, visible() {:.3} ms, settings hash64 {:.3} ms", set / f, vis / f, hash / f);
}
+136 -3
View File
@@ -9,7 +9,15 @@
//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU
//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores.
//! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `lightcraft-gpu`
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only).
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only, `GPU_ONLY=1` for the GPU only).
//!
//! `ONLY=window`: a 1:1 zoom window cut from the full-size original (the loupe's `Slot::Region`):
//! the first window of a photo, a pan, and exposure / draft / NR changes with a warm stage cache;
//! `DUMP=file` writes the GPU window's RGBA bytes, for pixel A/B comparisons between builds.
//! Opening a photo, per file: `ONLY=source` (decode steps), `ONLY=load` (what the app's loaders
//! cost: file read, decode, binning, embedded stand-ins, `load_bytes(2560)`, the fs loader),
//! `ONLY=load1` (decode checksum + `load_bytes(2560)`; `LOADONLY=1`: only that, for a profiler),
//! `ONLY=jpeg` (a JPEG decoded to fit 256–2560 px boxes).
use std::sync::Arc;
use std::time::Instant;
@@ -88,6 +96,63 @@ fn main() {
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
let only = std::env::var("ONLY").unwrap_or_default();
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
if only == "jpeg" {
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
for path in &args {
let bytes = std::fs::read(path).expect("read");
for edge in [256u32, 512, 1600, 2560] {
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
println!(
" {path} fit {edge}: {}×{} {}",
d.image.width,
d.image.height,
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
);
}
}
return;
}
if only == "load" || only == "load1" {
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
for path in &args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
if std::env::var("LOADONLY").is_ok() {
// only the preview load (for a profiler)
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
if let lightcraft_raw::RawData::U16(d) = &raw.data {
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
println!(" decode checksum: {sum:016x}");
}
if only == "load1" {
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
}
for edge in [2560usize, 1600] {
println!(
" {:<36} {}",
format!("embedded srgb, whole file ({edge}):"),
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
);
let quick = lightcraft_engine::files::fs_preview_loader();
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
}
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
let (loader, _) = lightcraft_engine::files::fs_hooks();
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
}
return;
}
if only == "source" {
// opening a photo: where the time goes before the first sharp loupe (per file)
for path in &args {
@@ -152,10 +217,16 @@ fn main() {
Some(a) => println!("gpu: {a} (device + kernels: {:.0} ms)", t.elapsed().as_secs_f64() * 1e3),
None => println!("gpu: none"),
}
let backends: &[bool] = if gpu { &[false, true] } else { &[false] };
// `GPU_ONLY=1`: skip the CPU column (profiling the GPU path)
let backends: &[bool] = match (gpu, std::env::var_os("GPU_ONLY").is_some()) {
(true, true) => &[true],
(true, false) => &[false, true],
_ => &[false],
};
let row = |name: &str, f: &mut dyn FnMut(bool)| {
let cols: Vec<String> = backends.iter().map(|&g| format!("{}", best(n, || f(g)))).collect();
println!("{name:<38} cpu: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
let first = if backends[0] { "gpu" } else { "cpu" };
println!("{name:<38} {first}: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
};
let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box));
let s = typical();
@@ -214,6 +285,68 @@ fn main() {
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
if run("window") {
// a 1:1 zoom window (the loupe's `Slot::Region`): visible 1920×1200 + margin, snapped to the
// 256 px grid, cut from the full-resolution original (`ui-egui` `region::window_for`)
use lightcraft_pipeline::PixelWindow;
let (fw, fh) = (full.width, full.height);
let win = |cx: usize, cy: usize| {
let snap = |v: usize| v / 256 * 256;
let (x, y) = (snap(cx.saturating_sub(960 + 512)), snap(cy.saturating_sub(600 + 512)));
PixelWindow { x, y, w: 3072.min(fw - x), h: 2304.min(fh - y) }
};
let at = |w: PixelWindow, q: Quality| RenderRequest { window: Some(w), quality: q, ..RenderRequest::fit(fw, fh) };
let w0 = win(fw / 2, fh / 2);
println!("window {}×{} at ({}, {}) of {fw}×{fh}", w0.w, w0.h, w0.x, w0.y);
// first window of a photo: nothing cached (the GPU uploads the original: `shared_source`)
row("window first (upload original):", &mut |g| {
lightcraft_engine::gpu::trim_pool(0);
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), g))
});
let wc = [StageCache::default(), StageCache::default()];
for g in backends {
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
}
let mut step = 0usize;
row("window pan (new window, warm src):", &mut |g| {
step += 1;
let w = win(fw / 2 + 512 * (step % 4 + 1), fh / 2);
drop(develop(&full, &info, &s, &at(w, Quality::Full), Some(&wc[g as usize]), g))
});
for g in backends {
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
}
row("window exposure change (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
});
row("window draft exposure drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&full, &info, &t, &at(w0, Quality::Draft), Some(&wc[g as usize]), g));
});
row("window NR change (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.detail.nr_luminance = 30.0 + k % 50.0;
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
});
// `DUMP=file`: the GPU window's raw RGBA bytes (A/B pixel comparisons between builds)
if let (true, Some(path)) = (gpu, std::env::var_os("DUMP")) {
let g = develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), true).image;
let bytes: Vec<u8> = g.data.iter().flatten().copied().collect();
std::fs::write(path, bytes).expect("dump");
}
if gpu && std::env::var_os("GPU_ONLY").is_none() {
let c = develop(&full, &info, &s, &at(w0, Quality::Full), None, false).image;
let g = develop(&full, &info, &s, &at(w0, Quality::Full), None, true).image;
let (max, mean) = diff(&c, &g);
println!("gpu vs cpu, window: max {max} LSB, mean {mean:.4} LSB");
}
}
if run("finish") {
// per-pixel stage cost by feature (warm cache: exposure changes only)
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
@@ -0,0 +1,63 @@
//! What a scan, an import and Synchronize Folder cost before they read the disk, and what
//! naming one photo's sidecar costs, by the size of the library (`N`, default 100,000 photos; the
//! folder looked at holds 500 of them).
//!
//! `N=3500000 cargo run --release -p lightcraft-engine --example scan_setup_bench`
use std::time::Instant;
use lightcraft_catalog::{Op, Photo, Source};
use lightcraft_engine::Session;
fn main() {
let n: u64 = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000);
let mut s = Session::new();
let t = Instant::now();
for chunk in (0..n).collect::<Vec<_>>().chunks(10_000) {
for i in chunk {
let id = s.catalog.alloc_photo_id();
let name = format!("IMG_{i:08}.jpg");
let path = format!("/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day {:05}/extra/{name}", i / 500);
let mut p = Photo::new(id, Source::File { path }, &name, "JPEG", 4032, 3024, "2026-09-30T12:00:00");
p.file_size = 3_400_000 + i;
p.captured = Some(format!("2019-07-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
}
eprintln!("{n} photos built in {:.2} s", t.elapsed().as_secs_f64());
let folder = "/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day 00000".to_string();
for run in 0..3 {
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
let f = lightcraft_catalog::Filter { library_folder: Some(folder.clone()), ..Default::default() };
let ids = s.catalog.query(&f, &lightcraft_catalog::Sort::default());
let query = ms(t);
let t = Instant::now();
let namings = s.sidecar_namings(&ids);
let naming = ms(t);
let t = Instant::now();
let one = ids.first().map(|id| s.sidecar_naming(*id));
let naming_one = ms(t);
let t = Instant::now();
let (input, paths) = lightcraft_engine::import::ScanInput::new(&mut s, std::slice::from_ref(&folder));
let scan = ms(t);
// (the folder is not on this disk: the scan finds no file, and its cost is what it makes of the library)
let t = Instant::now();
let out = lightcraft_engine::import::scan_with(input, &paths, &Default::default());
let scanned = ms(t);
let t = Instant::now();
let job = lightcraft_engine::import::ImportJob::new(&mut s, Default::default());
let import = ms(t);
let t = Instant::now();
let sync = lightcraft_engine::sync::SyncInput::new(&mut s, &folder, false);
let whole = ms(t);
println!(
"N={n} run {run}: query {query:.0} ms ({} ids) | sidecar_namings {naming:.0} ms | sidecar_naming of one {naming_one:.0} ms ({one:?}) | ScanInput::new {scan:.0} ms + scan_with {scanned:.0} ms ({} found) | ImportJob::new {import:.0} ms (ok {}) | SyncInput::new {whole:.0} ms (ok {})",
ids.len(),
out.candidates.len(),
job.is_ok(),
sync.is_ok()
);
let _ = namings;
}
}
+14 -6
View File
@@ -308,9 +308,12 @@ pub enum Outcome {
Imported(Imported),
/// Photos (or osascript) failed: why, for the user.
Failed(String),
/// Photos wasn't asked: the export that reserved the import was cancelled, wrote no files or
/// stopped early.
Skipped(String),
/// Photos wasn't asked: the export that reserved the import was cancelled (`cancelled`), wrote
/// no files or stopped early. `why` is for people; code decides on `cancelled`.
Skipped {
why: String,
cancelled: bool,
},
}
/// One import: reserved by an export still writing its files, running, or finished.
@@ -402,9 +405,12 @@ impl ImportJob {
v["running"] = json!(false);
v["error"] = json!(e);
}
Some(Outcome::Skipped(why)) => {
Some(Outcome::Skipped { why, cancelled }) => {
v["running"] = json!(false);
v["skipped"] = json!(why);
if cancelled {
v["cancelled"] = json!(true);
}
}
}
v
@@ -660,7 +666,7 @@ impl Reservation {
None
};
if let Some(why) = skipped {
job.finish(Outcome::Skipped(why.into()));
job.finish(Outcome::Skipped { why: why.into(), cancelled });
return Err(job.json());
}
if let Err(e) = check(&paths, &self.album) {
@@ -700,7 +706,7 @@ impl Reservation {
impl Drop for Reservation {
fn drop(&mut self) {
if let Some(job) = self.job.take() {
job.finish(Outcome::Skipped("the export ended before writing its files".into()));
job.finish(Outcome::Skipped { why: "the export ended before writing its files".into(), cancelled: false });
}
}
}
@@ -1009,8 +1015,10 @@ mod tests {
// nothing written, or cancelled: Photos isn't asked, and the slot is free again
let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false);
assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}");
assert_eq!(out.get("cancelled"), None, "{out}");
let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true);
assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}");
assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}");
assert_eq!(calls.lock().unwrap().len(), 1);
assert!(imports.running().is_none());
let _ = std::fs::remove_dir_all(dir);
+1
View File
@@ -345,6 +345,7 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
let decoded = crate::files::decode_raw_preview(
bytes,
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
None,
)?;
let mut reference = decoded.to_working();
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
+22 -13
View File
@@ -26,8 +26,11 @@ fn section_param(p: &Value, c: &str) -> Result<Section> {
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
}
fn groups_param(p: &Value) -> Option<Vec<SettingsGroup>> {
p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
fn groups_param(p: &Value, c: &str) -> Result<Option<Vec<SettingsGroup>>> {
match p.get("groups") {
None | Some(Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(c, format!("invalid `groups`: {e}"))),
}
}
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
@@ -497,13 +500,15 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| {
let ch = str_param(p, "channel").unwrap_or("master").to_string();
let pts: Vec<Point> = p
let mut pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| bad("develop.curve", "missing points"))?
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?.clamp(0.0, 1.0), q.get(1)?.as_f64()?.clamp(0.0, 1.0))))
.collect();
// stored in the order the curve runs, as the editor draws and drags them
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
edit(s, "develop.curve", "Tone Curve", |d| {
match ch.as_str() {
"master" | "rgb" => d.curve.master = pts,
@@ -782,8 +787,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
has_active,
|s, p| {
let id = active(s, "develop.copy")?;
if let Some(g) = groups_param(p) {
let c = "develop.copy";
let id = active(s, c)?;
if let Some(g) = groups_param(p, c)? {
s.copy_groups = g;
}
let d = s.develop_of(id).unwrap_or_default();
@@ -802,7 +808,7 @@ pub fn specs() -> Vec<CommandSpec> {
let c = "develop.pastePrevious";
let prev =
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
let ops = ids
@@ -823,8 +829,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
has_clipboard,
|s, p| {
let c = "develop.paste";
let mut clip = s.clipboard.clone().unwrap_or_default();
if let Some(only) = groups_param(p) {
if let Some(only) = groups_param(p, c)? {
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
@@ -849,8 +856,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{groups?} — copy the active photo's settings to all selected photos",
has_selection,
|s, p| {
let id = active(s, "develop.sync")?;
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let c = "develop.sync";
let id = active(s, c)?;
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let partial = lightcraft_develop::extract_groups(&d, &groups);
let ops = s
@@ -979,10 +987,11 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
let id = active(s, "preset.create")?;
let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
let c = "preset.create";
let id = active(s, c)?;
let name = str_param(p, "name").ok_or_else(|| bad(c, "missing name"))?;
let group = str_param(p, "group").unwrap_or("User Presets");
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
@@ -1026,7 +1035,7 @@ pub fn specs() -> Vec<CommandSpec> {
let c = "preset.update";
let id = active(s, c)?;
let current = s.develop_of(id).unwrap_or_default().to_json();
let groups = groups_param(p);
let groups = groups_param(p, c)?;
let pr = user_preset(s, p, c)?;
pr.settings = match groups {
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
+35 -9
View File
@@ -18,7 +18,7 @@ use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use crate::{Result, Session};
use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
/// Regions made by this command say so in their description; only those are replaced on a new run.
@@ -34,15 +34,39 @@ const EXISTING_IOU: f64 = 0.3;
/// What to tell someone who has not downloaded the detector yet.
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
/// Why [`detector`] has no detector to give.
#[derive(Debug)]
pub(crate) enum NoDetector {
/// Not downloaded yet: a frontend offers the download ([`crate::ErrorKind::ModelMissing`]).
NotInstalled,
/// Nowhere to keep it, or there but unusable: says what to do.
Unusable(String),
}
impl NoDetector {
/// Command `cmd`'s error for it. The message reads as before kinds existed (an invalid-parameters
/// error), so control-channel and MCP clients see the same `error` text.
pub(crate) fn into_error(self, cmd: &str) -> EngineError {
match self {
Self::NotInstalled => EngineError::ModelMissing { model: AiModel::FaceDetector, msg: bad(cmd, NOT_INSTALLED).to_string() },
Self::Unusable(why) => bad(cmd, why),
}
}
}
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String> {
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID);
let home = s
.face_models_dir
.as_deref()
.ok_or_else(|| NoDetector::Unusable("this build has nowhere to keep face models (the desktop app does)".into()))?
.join(YUNET_ID);
let path = home.join("model.onnx");
// installing writes the manifest last, so a folder without one is not (yet) a model
if !path.is_file() || !home.join("face-model.json").is_file() {
return Err(NOT_INSTALLED.into());
return Err(NoDetector::NotInstalled);
}
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((p, d)) = loaded.as_ref()
@@ -50,12 +74,14 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String
{
return Ok(d.clone());
}
let len = std::fs::metadata(&path).map_err(|e| format!("could not read the detector: {e}"))?.len();
let len = std::fs::metadata(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?.len();
if len > MAX_MODEL_BYTES {
return Err("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into());
return Err(NoDetector::Unusable("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into()));
}
let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?;
let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?);
let bytes = std::fs::read(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?;
let d = Arc::new(
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
);
*loaded = Some((path, d.clone()));
Ok(d)
}
@@ -103,7 +129,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),
max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),
};
let detector = detector(s).map_err(|e| bad(C, e))?;
let detector = detector(s).map_err(|e| e.into_error(C))?;
let started = Instant::now();
let (mut results, mut ops) = (Vec::new(), Vec::new());
for id in s.targets(p) {
+23 -15
View File
@@ -84,29 +84,29 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
match shape {
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
s.segmenter.model_dir().map_err(ai_err)?;
s.segmenter.require_model()?;
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
}
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
*seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
*seg = Some(segment(s, |s| s.segment_clicks(id, &clicks_of(hint, exclude)))?);
}
MaskShape::Object { .. } => {
// the clicks come later: refuse an Object mask that could never be computed (no
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
s.segmenter.model_dir().map_err(ai_err)?;
s.segmenter.require_model()?;
if background {
s.segment_prepare(id).map_err(ai_err)?;
segment(s, |s| s.segment_prepare(id))?;
}
}
MaskShape::Prompt { text, .. } if background => {
s.segmenter.model_dir().map_err(ai_err)?;
s.segmenter.require_model()?;
if text.trim().is_empty() {
return Err(ai_err("describe what to select"));
}
return Ok(Later::Text(text.clone()));
}
MaskShape::Prompt { text, seg, .. } => {
let found = s.segment_text(id, text).map_err(ai_err)?;
let found = segment(s, |s| s.segment_text(id, text))?;
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
}
_ => {}
@@ -118,7 +118,7 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
if let Later::Clicks(hint, exclude) = later {
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
segment(s, |s| s.segment_clicks_later(id, mask, comp, hint, exclude))?;
out["pending"] = json!(true);
}
Ok(out)
@@ -129,6 +129,13 @@ fn ai_err(msg: impl Into<String>) -> crate::EngineError {
crate::EngineError::Other(msg.into())
}
/// A segmentation call: the model is checked first, so a missing one is [`crate::EngineError::ModelMissing`]
/// (the segmenter's own errors are text).
fn segment<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> std::result::Result<T, String>) -> Result<T> {
s.segmenter.require_model()?;
f(s).map_err(ai_err)
}
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
}
@@ -167,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").map(str::to_string);
if let Later::Text(text) = &later {
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
segment(s, |s| s.segment_text_later(id, None, "add", name, text))?;
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
@@ -205,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
if let Later::Text(text) = &later {
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
segment(s, |s| s.segment_text_later(id, Some(mid), op, None, text))?;
return Ok(json!({"pending": true}));
}
let mut comp = 0;
@@ -365,10 +372,10 @@ pub fn specs() -> Vec<CommandSpec> {
}
if s.segmenter.background && !hint.is_empty() {
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
segment(s, |s| s.segment_clicks_later(id, mid, k, hint, ex))?;
return Ok(out);
}
let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
let seg = if hint.is_empty() { None } else { Some(segment(s, |s| s.segment_clicks(id, &clicks_of(&hint, &ex)))?) };
let out = json!({"include": hint.len(), "exclude": ex.len()});
masks_edit(s, c, "Object Mask", |masks, _| {
let i = find(masks, mid, c)?;
@@ -401,7 +408,7 @@ pub fn specs() -> Vec<CommandSpec> {
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
};
let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
let started = segment(s, |s| s.segment_detail(id, mid, k))?;
Ok(json!({"started": started}))
}
),
@@ -415,7 +422,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| {
let c = "segment.prepare";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
s.segment_prepare(id).map_err(ai_err)?;
segment(s, |s| s.segment_prepare(id))?;
Ok(json!({"busy": s.segmenter.busy()}))
}
),
@@ -639,7 +646,8 @@ pub fn specs() -> Vec<CommandSpec> {
let i = find(&mut masks, mid, "mask.move")?;
let to = match (p.get("to").and_then(Value::as_i64), p.get("delta").and_then(Value::as_i64)) {
(Some(t), _) => t,
(None, Some(d)) => i as i64 + d,
// (an agent's delta can be anything: saturate rather than overflow)
(None, Some(d)) => (i as i64).saturating_add(d),
(None, None) => return Err(bad("mask.move", "give `to` (index) or `delta`")),
}
.clamp(0, masks.len() as i64 - 1) as usize;
@@ -700,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
let detector = super::face_detect::detector(s).map_err(|e| e.into_error(c))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
+13 -3
View File
@@ -31,14 +31,24 @@ pub fn photo_summary(p: &Photo) -> Value {
})
}
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value {
/// How deep [`album_json`] lists folders inside folders: a catalog that loops (an album inside
/// itself, which [`lightcraft_catalog::Catalog::repair`] puts right on load) lists no further
/// instead of overflowing the stack.
const MAX_ALBUM_DEPTH: usize = 64;
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
let children: Vec<Value> = if depth < MAX_ALBUM_DEPTH {
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect()
} else {
Vec::new()
};
let mut v = json!({
"id": a.id.0,
"name": a.name,
"folder": a.folder,
"count": cat.album_count(a.id),
"cover": a.cover.map(|c| c.0),
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::<Vec<_>>(),
"children": children,
});
if let Some(rules) = &a.smart {
v["smart"] = json!(true);
@@ -139,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect()))
}),
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "photo.inspect")?;
+3 -2
View File
@@ -12,8 +12,9 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let mut merged = Vec::new();
let mut backups = Vec::new();
let mut failed = Vec::new();
let owners = crate::sidecar::StemOwners::of(&s.catalog);
for id in s.targets(p) {
let ids = s.targets(p);
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
for id in ids {
match s.save_sidecar_with(id, &owners) {
Ok(r) => {
let path = r.path.display().to_string();
+129 -16
View File
@@ -1,16 +1,31 @@
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera
//! profiles, face and denoise models).
//!
//! [`config_dir`] picks it, in order:
//! 1. `$LIGHTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
//! 2. **Portable mode** (issue #1200, as in PhotoCraft): a marker file ([`PORTABLE_MARKERS`]) beside the
//! executable puts everything in `<exe dir>/LightCraftData`, so a copy on a USB stick leaves nothing in
//! `%APPDATA%`. If that folder can't be written (read-only medium, Program Files), a warning is logged
//! and the platform folder is used.
//! 3. The platform convention: macOS `~/Library/Application Support/LightCraft`, Windows
//! `%APPDATA%\LightCraft`, Linux `$XDG_CONFIG_HOME/lightcraft` or `~/.config/lightcraft`.
//!
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic.
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::Session;
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "LightCraft.portable"];
/// The data folder created beside the executable in portable mode.
pub const PORTABLE_DATA_DIR: &str = "LightCraftData";
#[derive(Clone, Copy)]
enum Platform {
Mac,
@@ -18,9 +33,9 @@ enum Platform {
Other,
}
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides portable mode and the
/// platform default. The override is the complete directory; no application-name suffix is appended. An
/// explicitly empty value disables the settings directory instead of falling back to a personal profile.
pub fn config_dir() -> Option<PathBuf> {
let platform = if cfg!(target_os = "macos") {
Platform::Mac
@@ -29,13 +44,17 @@ pub fn config_dir() -> Option<PathBuf> {
} else {
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
config_dir_with(platform, |name| std::env::var_os(name), portable_dir)
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
/// `portable` is asked only without an override, so an isolated run never probes beside the executable.
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
if let Some(dir) = portable() {
return Some(dir);
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
@@ -45,6 +64,50 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
}
}
/// This process's portable data folder, if a marker sits beside its executable and the folder can be
/// written. Resolved once: the check creates the folder and writes a probe file.
fn portable_dir() -> Option<PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DIR.get_or_init(|| {
let exe = std::env::current_exe().ok()?;
match portable_dir_in(exe.parent()?) {
Ok(dir) => dir,
Err(why) => {
log::warn!("{why}");
None
}
}
})
.clone()
}
/// `<exe_dir>/LightCraftData` when `exe_dir` holds a portable marker; `Err` says why a marked folder
/// can't be used (the caller falls back to the platform folder).
fn portable_dir_in(exe_dir: &Path) -> Result<Option<PathBuf>, String> {
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) else {
return Ok(None);
};
let data = exe_dir.join(PORTABLE_DATA_DIR);
match ensure_writable(&data) {
Ok(()) => Ok(Some(data)),
Err(e) => Err(format!(
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the usual folder instead",
marker.display(),
data.display()
)),
}
}
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
// A leftover probe is harmless; don't fail portable mode over it.
let _ = std::fs::remove_file(&probe);
Ok(())
}
/// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `<config>/models`.
pub fn default_face_models_dir() -> Option<PathBuf> {
std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models")))
@@ -101,16 +164,62 @@ mod config_tests {
use super::*;
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), || None)
}
/// An override is used as is: portable mode is not even looked for.
fn no_portable_probe() -> Option<PathBuf> {
panic!("an override must not probe for portable mode")
}
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-config-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A marker beside the executable moves the settings folder next to it, ahead of the platform folder (issue #1200).
#[test]
fn portable_marker_puts_settings_beside_the_executable() {
for marker in PORTABLE_MARKERS {
let exe = temp(&format!("portable-{marker}"));
assert_eq!(portable_dir_in(&exe), Ok(None), "no marker, no portable folder");
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
std::fs::write(exe.join(marker), b"").unwrap();
let data = exe.join(PORTABLE_DATA_DIR);
assert_eq!(portable_dir_in(&exe), Ok(Some(data.clone())));
assert!(data.is_dir());
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0, "the write probe is removed");
let found = portable_dir_in(&exe).unwrap();
let actual = config_dir_with(Platform::Windows, |name| (name == "APPDATA").then(|| OsString::from("/roaming")), || found);
assert_eq!(actual, Some(data));
let _ = std::fs::remove_dir_all(&exe);
}
}
/// A marked folder that can't hold the data folder (here: a file is in its place) is reported, not used.
#[test]
fn portable_folder_that_cannot_be_written_is_refused() {
let exe = temp("portable-blocked");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"").unwrap();
let why = portable_dir_in(&exe).unwrap_err();
assert!(why.contains("portable.txt") && why.contains(PORTABLE_DATA_DIR), "{why}");
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
let actual = config_dir_with(
platform,
|name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
},
no_portable_probe,
);
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
@@ -119,10 +228,14 @@ mod config_tests {
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
config_dir_with(
platform,
|name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::new())
},
no_portable_probe
),
None
);
}
@@ -145,7 +258,7 @@ mod config_tests {
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), no_portable_probe), Some(PathBuf::from(value)));
}
}
+15 -1
View File
@@ -173,7 +173,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
let mut pts = Vec::new();
for it in items {
let (x, y) = it.split_once(',')?;
let (x, y) = (x.trim().parse::<f64>().ok()?, y.trim().parse::<f64>().ok()?);
// (`parse` reads "NaN" and "inf" too: a curve with such a point is not a curve)
let (x, y) = (x.trim().parse::<f64>().ok().filter(|v| v.is_finite())?, y.trim().parse::<f64>().ok().filter(|v| v.is_finite())?);
pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)}));
}
if pts.len() < 2 {
@@ -617,6 +618,19 @@ mod tests {
assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
}
/// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve:
/// it is left out, rather than stored with a point the curve editor can't place.
#[test]
fn curves_with_non_finite_points_are_left_out() {
for bad in ["NaN, 0", "64, inf", "-inf, 3"] {
let x = SIDECAR.replace("<rdf:li>64, 52</rdf:li>", &format!("<rdf:li>{bad}</rdf:li>"));
let partial = to_partial(&props(&x), Target::RawAbsolute);
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master);
assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
}
#[test]
fn only_present_fields_are_in_the_partial() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
+68
View File
@@ -0,0 +1,68 @@
//! What kind of error a command returned, for code that decides on it (#1046).
//!
//! A frontend that reacts to an error (offers a model download, says "cancelled" in its own words
//! instead of showing a failure) matches [`EngineError::kind`], never the message: messages are
//! for people, get reworded, and will be translated.
use crate::EngineError;
/// A model a feature needs and LightCraft downloads only when the person agrees.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AiModel {
/// YuNet, which finds faces (`faces.detect`; installed in Settings ▸ Faces).
FaceDetector,
/// SAM 3, behind Object and Describe masks (`segment.model.download`).
Sam3,
}
/// The kind of an [`EngineError`] (or of an error a frontend raised itself: [`ErrorKind::Other`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ErrorKind {
UnknownCommand,
Disabled,
BadParams,
Catalog,
NotSaved,
LibraryInUse,
LightroomCatalog,
/// The work was cancelled: not a failure.
Cancelled,
/// The feature needs this model, which is not installed (or still downloading).
ModelMissing(AiModel),
Other,
}
impl EngineError {
/// What kind of error this is: decide on this, not on the message.
pub fn kind(&self) -> ErrorKind {
match self {
Self::UnknownCommand(_) => ErrorKind::UnknownCommand,
Self::Disabled(..) => ErrorKind::Disabled,
Self::BadParams { .. } => ErrorKind::BadParams,
Self::Catalog(_) => ErrorKind::Catalog,
Self::NotSaved(_) => ErrorKind::NotSaved,
Self::LibraryInUse(_) => ErrorKind::LibraryInUse,
Self::LightroomCatalog { .. } => ErrorKind::LightroomCatalog,
Self::Cancelled(_) => ErrorKind::Cancelled,
Self::ModelMissing { model, .. } => ErrorKind::ModelMissing(*model),
Self::Other(_) => ErrorKind::Other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_kind_does_not_depend_on_the_message() {
let cancelled = EngineError::Cancelled("Lightroom-Import abgebrochen".into());
assert_eq!(cancelled.kind(), ErrorKind::Cancelled);
assert_eq!(cancelled.to_string(), "Lightroom-Import abgebrochen", "the message is shown as is");
let missing = EngineError::ModelMissing { model: AiModel::Sam3, msg: "any wording".into() };
assert_eq!(missing.kind(), ErrorKind::ModelMissing(AiModel::Sam3));
assert_eq!(missing.to_string(), "any wording");
// an `Other` whose text reads like a cancellation or a missing model is still `Other`
assert_eq!(EngineError::Other("The SAM 3 model is not installed; cancelled".into()).kind(), ErrorKind::Other);
}
}
+98 -3
View File
@@ -108,6 +108,10 @@ impl Default for Resize {
}
}
/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also
/// Lightroom's ceiling. A full-size or smaller export is never held to it.
pub const MAX_ENLARGED_PIXELS: f64 = 512e6;
impl Resize {
pub fn long_edge(px: u32) -> Self {
Self { value: px as f32, ..Default::default() }
@@ -131,8 +135,12 @@ impl Resize {
ResizeMode::Percent => v / 100.0,
};
let k = if self.dont_enlarge { k.min(1.0) } else { k };
// at least one pixel, at most the encoders' 65535 limit
k.clamp(1.0 / short, 65_535.0 / long)
// an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000
// with Don't Enlarge off asked for gigapixels, more memory than a computer has
let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0));
// at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more
// than 65535 times as long as it is short (where `clamp` would panic: min above max)
k.max(1.0 / short).min(65_535.0 / long)
}
/// Output size for a `w × h` (cropped, full-resolution) photo.
@@ -1670,10 +1678,16 @@ fn prepare_guarded(
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
let (work, weight) = if o.format.is_rendered() {
let (w, h) = output_size(p, o);
let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize;
let label = p.file_name.clone();
let meta = export_metadata(p, &session.catalog, o);
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {
return Err(format!("{label}: {why}"));
}
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
} else {
@@ -1697,6 +1711,26 @@ fn prepare_guarded(
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
}
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export
/// enlarged past the photo's size that needs more memory than the computer has would fail to
/// allocate, which ends the process rather than the export. Full-size and smaller exports, and
/// computers whose memory isn't known, are never refused.
fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option<usize>) -> Option<String> {
let ram = ram?;
if out.0.saturating_mul(out.1) <= native_px || weight <= ram {
return None;
}
let gb = |b: usize| b as f64 / (1u64 << 30) as f64;
Some(format!(
"exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size",
out.0,
out.1,
gb(weight).ceil(),
gb(ram)
))
}
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
@@ -2738,10 +2772,44 @@ mod tests {
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
// never larger than the encoders' limit, never empty
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((65535, 43690)));
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most");
assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge");
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
}
/// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the
/// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the
/// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos
/// larger than that are untouched.
#[test]
fn enlargements_stop_at_512_megapixels() {
let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap();
for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] {
let (w, h) = size(p.clone(), 6000.0, 4000.0);
assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}");
assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape");
}
// below the ceiling an enlargement is as asked
assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000));
// a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged
assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000));
assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
}
/// An enlarged export that needs more memory than the computer has is refused with a message,
/// before it starts; everything else goes ahead.
#[test]
fn enlarged_exports_must_fit_the_computers_memory() {
let gb = 1usize << 30;
let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap();
assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}");
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits");
assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused");
assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export");
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory");
}
#[test]
fn resize_size_survives_the_render() {
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
@@ -2783,6 +2851,33 @@ mod tests {
}
}
/// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale`
/// clamp with its lower bound above its upper one, which panics, in the export and in the
/// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a
/// pixel.
#[test]
fn resize_survives_photos_too_long_for_the_encoders() {
let modes = [
json!({"longEdge": 2000}),
json!({"shortEdge": 1}),
json!({"width": 500, "height": 500}),
json!({"megapixels": 1}),
json!({"percent": 50}),
json!({"percent": 1000, "dontEnlarge": false}),
];
for p in modes {
for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] {
let r = ExportOptions::from_json(&p).resize.unwrap();
let (ow, oh) = r.apply(w, h);
assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}");
assert!(r.scale(w, h).is_finite());
}
}
// (a short edge under a pixel would be scaled up to one, which the limit then caps)
assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1));
assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535));
}
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
fn p3_green(space: OutputSpace) -> Rgba8 {
use lightcraft_color::{DISPLAY_P3, REC2020};
+143
View File
@@ -0,0 +1,143 @@
//! Face tiles cut from a raw's embedded preview (issue #1440).
//!
//! A People card's face picture ([`crate::Session::face_job`]) is a small square around the face.
//! Developing the raw for it reads and demosaics most of a 24–50 MB file for a ~170 px picture; the
//! camera's own JPEG inside the file (read through the planned preview path,
//! [`crate::files::preview_window`], which reads the header and the preview and not the sensor
//! data) usually holds that square at the tile's size already. A [`PreviewCut`] says which preview
//! to read and how large it must be; [`crate::media::RenderJob::run`] tries it before the job's own
//! source and falls back to that source when the preview can't serve.
//!
//! Only a raw that still looks the way it was imported gets one: that is the photo whose grid
//! thumbnail is the same embedded JPEG ([`crate::Session::embedded_thumb_is_final`]). An edited raw
//! needs its edits rendered, which the camera's display-referred JPEG can't take the way the raw
//! does, so it keeps the full develop render.
use lightcraft_geom::Rect;
use lightcraft_raster::Rgba8;
use lightcraft_raster::resample::{Filter, resize};
use crate::media::PreviewLoader;
/// How much smaller than asked an embedded preview may be and still serve a face tile: at least
/// three quarters of the pixels the tile asks for across the face. The square is then enlarged by
/// at most 4⁄3 on its way to the tile, which at a 56–180 pt tile is not visible (the camera's JPEG
/// is sharpened and denoised, so its pixels hold more detail than a binned raw's), and it is the
/// same bound the raw path already accepts for an X-Trans sensor binned 3× for a preview
/// ([`crate::files::bin_factor`]). Below it the tile would look soft: the job's own source renders it.
pub const SOFT_LIMIT: f64 = 0.75;
/// Largest relative difference between the preview's and the photo's aspect ratios: a preview of
/// another shape (letterboxed, 16:9 from a 3:2 sensor, a 4:3 sensor's 3:2 JPEG) does not show the
/// same frame, and the face box would land beside the face. Real previews of the same frame differ
/// by rounding only (Sony A7S III: 1616 × 1080 against 4240 × 2832, 0.06 %).
const ASPECT_TOLERANCE: f64 = 0.02;
/// A face tile to cut from the photo's embedded preview.
#[derive(Clone)]
pub struct PreviewCut {
/// The raw file.
pub path: String,
/// Reads its largest embedded preview, display-ready and upright, no larger than the edge given.
pub loader: PreviewLoader,
/// The preview's long edge that gives the tile its own size across the face.
pub want: usize,
/// The smallest long edge accepted ([`SOFT_LIMIT`] of `want`).
pub min: usize,
/// The square around the face, normalized on the upright photo.
pub rect: Rect,
/// The photo's width / height, upright.
pub aspect: f64,
/// The tile's edge, pixels.
pub edge: usize,
}
impl PreviewCut {
/// The tile, or `None` when the preview can't give it: none could be read (no preview, an
/// offline or damaged file), it is smaller than [`Self::min`], or it shows another frame.
pub fn cut(&self) -> Option<Rgba8> {
let preview = (self.loader)(&self.path, self.want)?;
self.cut_from(&preview)
}
/// [`Self::cut`] from a preview already read.
pub fn cut_from(&self, img: &Rgba8) -> Option<Rgba8> {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 || w.max(h) < self.min || img.data.len() != w.checked_mul(h)? {
return None;
}
let aspect = w as f64 / h as f64;
if !(self.aspect.is_finite() && self.aspect > 0.0) || ((aspect - self.aspect) / self.aspect).abs() > ASPECT_TOLERANCE {
return None;
}
let r = self.rect;
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return None;
}
// pixel bounds inside the preview (the clamps keep every value in 0..=w / 0..=h)
let px = |v: f64, n: usize| (v.clamp(0.0, 1.0) * n as f64).round() as usize;
let (x0, x1, y0, y1) = (px(r.x0, w), px(r.x1, w), px(r.y0, h), px(r.y1, h));
if x1 <= x0 || y1 <= y0 {
return None;
}
let edge = self.edge.clamp(1, crate::media::SourceLevel::Thumb.max_edge());
let square = img.crop(x0, y0, x1 - x0, y1 - y0).to_linear();
Some(resize(&square, edge, edge, Filter::Mitchell).to_srgb8())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn cut(rect: Rect, aspect: f64, min: usize) -> PreviewCut {
PreviewCut { path: String::new(), loader: Arc::new(|_, _| None), want: 300, min, rect, aspect, edge: 32 }
}
fn picture(w: usize, h: usize) -> Rgba8 {
let mut img = Rgba8::new(w, h);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 90, 255];
}
img
}
/// The square comes out the tile's size, from the place the box names.
#[test]
fn cuts_the_square_at_the_tiles_size() {
let img = picture(300, 200);
let out = cut(Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.65 }, 1.5, 200).cut_from(&img).unwrap();
assert_eq!((out.width, out.height), (32, 32));
// red follows x: the box starts at half the width
let red = out.data[16 * 32 + 16][0];
assert!((125..=160).contains(&red), "{red}");
}
/// A preview too small, of another shape, empty, or a box that is no box: no tile, no panic.
#[test]
fn declines_what_it_cannot_cut() {
let img = picture(300, 200);
let ok = Rect { x0: 0.4, y0: 0.4, x1: 0.5, y1: 0.55 };
assert!(cut(ok, 1.5, 301).cut_from(&img).is_none(), "below the soft limit");
assert!(cut(ok, 16.0 / 9.0, 200).cut_from(&img).is_none(), "another frame");
assert!(cut(ok, f64::NAN, 200).cut_from(&img).is_none());
assert!(cut(ok, 0.0, 200).cut_from(&img).is_none());
assert!(cut(ok, 1.5, 0).cut_from(&Rgba8::new(0, 0)).is_none(), "empty preview");
let short = Rgba8 { width: 300, height: 200, data: vec![[0; 4]; 10] };
assert!(cut(ok, 1.5, 0).cut_from(&short).is_none(), "a buffer shorter than its size");
for bad in [
Rect { x0: f64::NAN, y0: 0.4, x1: 0.5, y1: 0.5 },
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
Rect { x0: 0.6, y0: 0.6, x1: 0.4, y1: 0.4 },
Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 },
] {
assert!(cut(bad, 1.5, 0).cut_from(&img).is_none(), "{bad:?}");
}
// a box past the edges is clamped to the picture
let edge = cut(Rect { x0: -1.0, y0: -1.0, x1: 0.1, y1: 0.15 }, 1.5, 0).cut_from(&img).unwrap();
assert_eq!((edge.width, edge.height), (32, 32));
// and a loader with nothing to give leaves the job to its source
assert!(cut(ok, 1.5, 0).cut().is_none());
}
}
+152 -48
View File
@@ -450,7 +450,8 @@ fn load_bytes_now(
stages.push(("highlights", t0.elapsed()));
colour.apply(&mut img);
stages.push(("colour", t0.elapsed()));
let img = fit(&img, max_edge, max_edge, Filter::Box);
// a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP)
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img };
stages.push(("fit", t0.elapsed()));
let img = img.into_oriented(raw.orientation);
stages.push(("orient", t0.elapsed()));
@@ -622,11 +623,16 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
}
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is
/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first
/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image
/// bytes for [`lightcraft_codecs::decode`].
fn stand_in_image(bytes: &[u8], covering: Option<usize>) -> Option<std::borrow::Cow<'_, [u8]>> {
let jpeg = match covering {
Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)),
None => lightcraft_raw::embedded_preview(bytes),
};
if let Some(jpeg) = jpeg {
return Some(std::borrow::Cow::Owned(jpeg));
}
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
@@ -634,9 +640,14 @@ fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
.then_some(std::borrow::Cow::Borrowed(bytes))
}
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
let jpeg = stand_in_image(bytes)?;
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see
/// [`stand_in_image`]).
pub(crate) fn decode_raw_preview(
bytes: &[u8],
opts: lightcraft_codecs::DecodeOptions,
covering: Option<usize>,
) -> Option<lightcraft_codecs::Decoded> {
let jpeg = stand_in_image(bytes, covering)?;
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
@@ -653,7 +664,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
/// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
let jpeg = stand_in_image(bytes)?;
let jpeg = stand_in_image(bytes, None)?;
// headers only, as for other images (issue #367): decoding a full-size preview just for its
// size took about a second per file in an import; a decode remains the fallback
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
@@ -671,16 +682,18 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
// always the largest JPEG: a source's geometry must not depend on its size level (previews match exports)
let d = decode_raw_preview(bytes, fit_box(max_edge), None)?;
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
}
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest
/// embedded JPEG that covers `max_edge` is decoded, not the largest.
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
@@ -708,7 +721,11 @@ pub fn fs_preview_loader() -> PreviewLoader {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
if let Some(image) = preview_window(path, max_edge)
.ok()
.flatten()
.and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge))
{
return Some(image);
}
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
@@ -729,6 +746,9 @@ pub struct PreviewRead {
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
/// the header path, and on the whole-file fallback).
pub planned: bool,
/// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise
/// (`decode_raw_preview` applies the same declaration): the quick decode applies it too.
pub adobe_rgb: bool,
}
impl PreviewRead {
@@ -743,7 +763,9 @@ impl PreviewRead {
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
/// which is the caller's cue to read the file whole.
/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps
/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest
/// that covers it ([`lightcraft_raw::embedded_preview_covering`]).
///
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
@@ -751,7 +773,7 @@ impl PreviewRead {
/// still needs the whole container, so the header comes back with the window; for a preview taken
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
/// whole-file fallback stays in charge.
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
pub fn preview_window(path: &str, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
@@ -773,7 +795,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
// sparse planner which ranges `embedded_preview` reads before handing the caller the
// whole-file fallback.
return planned_preview(&mut file, path, total);
return planned_preview(&mut file, path, total, max_edge);
};
// A preview at the file's end may have been padded past it: clamp rather than fail.
let end = range.offset.saturating_add(range.window_len).min(total);
@@ -796,7 +818,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
return Ok(None);
}
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false };
// On a network share the bytes are the wait, so say what this preview actually cost. A window
// taken from the prefix cost nothing beyond the prefix itself.
if lightcraft_pipeline::profiling() {
@@ -838,7 +860,7 @@ const PLAN_PREFIX: u64 = 64 * 1024;
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
/// gives what the whole file gives), so a window that still fails to decode ends in the same
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
if total > MAX_PREVIEW_PLAN_BYTES {
@@ -867,19 +889,20 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<O
}
}
lightcraft_raw::sparse::HeaderPlan::Ready => {
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
// the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick
let edge = u32::try_from(max_edge).unwrap_or(u32::MAX);
let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) };
// The quick path hands the JPEG to the decoder alone, so what the *container* says
// about the preview has to be something the bytes it is handed can show. Two container
// facts change the pixels and are not in the JPEG: the preview's colour space (which
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
// no orientation of its own, the file's EXIF orientation. Where either is present and
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
// it already does for a container-declared colour space. A container that says sRGB
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
// space declines.
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
return Ok(None);
}
// no orientation of its own, the file's EXIF orientation. The colour space is read
// here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews,
// which used to send each of them to the whole-file path, issue #1440); a container
// that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB).
// Where the orientation is present and the header cannot answer for it, the
// whole-file path keeps the preview.
let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb);
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
// first: a JPEG that carries its own orientation needs nothing from the container.
if lightcraft_meta::extract(&window).orientation.is_none()
@@ -888,7 +911,7 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<O
{
return Ok(None);
}
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb };
// A plan-driven preview is the one that used to cost the whole file, so it is worth
// saying what it cost now too.
if lightcraft_pipeline::profiling() {
@@ -937,9 +960,15 @@ fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
/// `None` when the bytes are not a decodable image.
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied
/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image.
fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) {
lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb)
} else {
lightcraft_codecs::decode(window, fit_box(max_edge))
};
let mut d = decoded.ok()?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
@@ -1125,7 +1154,7 @@ mod tests {
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview");
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
assert!(read.header_path, "the header path is what answered");
assert!(!read.planned, "a RAF pointer in the header needs no planner");
@@ -1134,7 +1163,7 @@ mod tests {
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
// The quick path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
@@ -1142,8 +1171,8 @@ mod tests {
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
let plain = dir.join("plain.jpg");
std::fs::write(&plain, &jpeg).unwrap();
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none());
assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1163,6 +1192,12 @@ mod tests {
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
tiff_with_preview_strips(strip, &[], tail, what)
}
/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its
/// 1616 px preview beside the full-size one.
fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut preview = IfdBuilder::new();
if what.dng {
@@ -1189,6 +1224,15 @@ mod tests {
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
for jpeg in smaller {
let mut sub = IfdBuilder::new();
sub.set(t::COMPRESSION, Value::Short(vec![7]));
sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
sub.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
sub.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] });
preview.add_sub_ifd(sub);
}
let mut raw = IfdBuilder::new();
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
@@ -1216,7 +1260,7 @@ mod tests {
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview");
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
assert!(read.planned, "the planner is what answered");
assert!(read.header_path, "and it is still a bounded read");
@@ -1225,7 +1269,7 @@ mod tests {
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
// The plan path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
@@ -1234,7 +1278,39 @@ mod tests {
// caller's whole-file read is what answers (no guessing at what a file might hold).
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
std::fs::write(&path, &not_a_raw).unwrap();
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back");
let _ = std::fs::remove_dir_all(&dir);
}
/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that
/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an
/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one.
#[test]
fn preview_loading_plans_the_smallest_covering_jpeg() {
let jpeg = |w: u32, h: u32| {
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect();
encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap()
};
let (big, small) = (jpeg(96, 64), jpeg(48, 32));
assert!(big.len() > small.len(), "the full-size JPEG is the largest");
let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() });
let dir = crate::tests_xmp::temp_dir("preview-plan-covering");
let path = dir.join("two-previews.dng");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] {
let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview");
assert!(read.planned, "the planner is what answered");
assert!(&window == want, "a {edge} px box takes the {which} JPEG");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too");
assert_eq!(
(quick.width, quick.height, quick.data),
(whole.width, whole.height, whole.data),
"{edge} px: the plan and the whole file agree"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1262,17 +1338,17 @@ mod tests {
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it");
let _ = std::fs::remove_dir_all(&dir);
}
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies
/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and
/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one
/// that declares sRGB (what most bodies write) keeps the fast path as before.
#[test]
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
fn preview_loading_applies_a_container_colour_space() {
use lightcraft_raw::PreviewColorSpace;
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
@@ -1285,13 +1361,20 @@ mod tests {
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
std::fs::write(&path, &adobe).unwrap();
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too");
assert!(read.planned && read.adobe_rgb, "{read:?}");
assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes");
let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too");
assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it");
let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes");
assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)");
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
std::fs::write(&path, &srgb).unwrap();
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
assert!(read.planned, "the plan still answers");
let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
assert!(read.planned && !read.adobe_rgb, "the plan still answers");
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
}
@@ -2296,4 +2379,25 @@ mod tests {
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the
/// embedded preview can't be read (and nothing is cached) the quick job itself must render the
/// raw, or the cell stays empty for good ("no quick preview").
#[test]
fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() {
use crate::media::QuickSource;
let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.cr3");
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
let mut s = crate::Session::new().with_fs();
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None));
let job = s.thumb_job(id, 128).unwrap();
let q = s.quick_thumb_job(&job).unwrap().run();
assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key));
assert!(q.rendered.is_ok(), "{:?}", q.rendered.err());
let _ = std::fs::remove_dir_all(&dir);
}
}
+103 -45
View File
@@ -548,12 +548,62 @@ fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>
pub struct ScanInput {
probe: Option<crate::media::FileProbe>,
skip: Option<PathBuf>,
/// path → (photo, its summary) for files already in the library.
by_path: HashMap<PathBuf, (PhotoId, ImportCandidate)>,
by_hash: HashMap<String, PhotoId>,
/// The library as it is now (photos are shared with the session, not copied): looked up for
/// the files the scan finds ([`library_files`]) and the contents it probes
/// ([`library_hashes`]), on the scan's thread.
library: std::sync::Arc<lightcraft_catalog::Catalog>,
cache: HashMap<String, ProbeInfo>,
}
/// The library's photos of `files`: path → (photo, its summary as a candidate that is there
/// already). One pass over the library, and a record only for a file that was found: a folder of
/// a hundred files in a library of millions costs a hundred records.
fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap<PathBuf, (PhotoId, ImportCandidate)> {
let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect();
let mut by_path = HashMap::new();
if found.is_empty() {
return by_path;
}
for p in library.photos() {
if let Source::File { path } = &p.source
&& found.contains(Path::new(path))
{
let c = ImportCandidate {
path: path.clone(),
format: p.format.clone(),
kind: p.kind,
width: p.width,
height: p.height,
file_size: p.file_size,
captured: p.captured.clone(),
duplicate: Some("path".into()),
existing: Some(p.id.0),
..Default::default()
};
by_path.insert(PathBuf::from(path), (p.id, c));
}
}
by_path
}
/// Which photo of the library has each of the contents `hashes` (the later one where several
/// have it). One pass over the library, and an entry only for a content that was asked about.
fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator<Item = &'a str>) -> HashMap<String, PhotoId> {
let wanted: std::collections::HashSet<&str> = hashes.collect();
let mut by_hash = HashMap::new();
if wanted.is_empty() {
return by_hash;
}
for p in library.photos() {
if let Some(h) = &p.content_hash
&& wanted.contains(h.as_str())
{
by_hash.insert(h.clone(), p.id);
}
}
by_hash
}
/// Progress and cancellation of a running [`scan_with`].
#[derive(Default)]
pub struct ScanProgress {
@@ -573,31 +623,17 @@ pub struct ScanOutput {
impl ScanInput {
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec<String>) {
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
let mut by_path = HashMap::new();
let mut by_hash = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
let c = ImportCandidate {
path: path.clone(),
format: p.format.clone(),
kind: p.kind,
width: p.width,
height: p.height,
file_size: p.file_size,
captured: p.captured.clone(),
duplicate: Some("path".into()),
existing: Some(p.id.0),
..Default::default()
};
by_path.insert(PathBuf::from(path), (p.id, c));
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
// (no table of the library here: this is the session's thread, and the library may hold
// millions of photos. The scan looks up what it finds)
let library = std::sync::Arc::new(s.catalog.clone());
let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) };
(input, paths.to_vec())
}
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
std::sync::Arc::clone(&self.library)
}
}
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
@@ -615,7 +651,8 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
let files = expand(paths, input.skip.as_deref());
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(Path::new(f))).cloned().collect();
let by_path = library_files(&input.library, &files);
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
progress.total.store(todo.len(), Relaxed);
// probes from a preceding `scan` are reused when the file is unchanged (same size)
let cached: Vec<Option<ProbeInfo>> = todo
@@ -633,11 +670,14 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
// (who in the library has a content that was probed: asked once the probes are in)
let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref());
let mut seen_hash: HashMap<String, Option<u64>> =
library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect();
let mut out = Vec::with_capacity(files.len());
let mut kept = HashMap::new();
for f in files {
if let Some((_, c)) = input.by_path.get(Path::new(&f)) {
if let Some((_, c)) = by_path.get(Path::new(&f)) {
let mut c = c.clone();
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
out.push(c);
@@ -701,6 +741,12 @@ pub struct ImportJob {
mode: ImportMode,
lib_dir: Option<PathBuf>,
copy_root: Option<PathBuf>,
/// The library as it was when the import was set up, until it is looked up
/// ([`ImportJob::look_up_library`], with the first files): setting an import up is the
/// session's thread, and a table of a library of millions of photos takes seconds. With it,
/// whether a photo in Recently Deleted is passed over (an import that replaces such photos,
/// as the options said when the import was set up).
library: Option<(lightcraft_catalog::Catalog, bool)>,
/// path → (photo, a Local browse record?)
by_path: HashMap<String, (PhotoId, bool)>,
/// content hash → the photo that has it (`None`: a file earlier in this import)
@@ -842,28 +888,16 @@ impl ImportJob {
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
let mut by_path = HashMap::new();
let mut by_hash = HashMap::new();
for p in s.catalog.photos() {
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
if p.deleted && opts.on_deleted == OnDeleted::Fresh {
continue;
}
if let Source::File { path } = &p.source {
by_path.insert(path.clone(), (p.id, p.local));
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), Some(p.id));
}
}
let fresh = opts.on_deleted == OnDeleted::Fresh;
Ok(ImportJob {
seq: opts.rename_start.max(1),
opts,
mode,
lib_dir,
copy_root,
by_path,
by_hash,
library: Some((s.catalog.clone(), fresh)),
by_path: HashMap::new(),
by_hash: HashMap::new(),
probe: s.media.file_probe.clone(),
file_bytes: s.media.file_bytes.clone(),
cache: std::mem::take(&mut s.import_probes),
@@ -898,9 +932,28 @@ impl ImportJob {
&self.now
}
/// What the library held when the import was set up, by path and by content: made once, with
/// the first files (on the thread that prepares them), from the library as it was then.
fn look_up_library(&mut self) {
let Some((library, fresh)) = self.library.take() else { return };
for p in library.photos() {
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
if p.deleted && fresh {
continue;
}
if let Source::File { path } = &p.source {
self.by_path.insert(path.clone(), (p.id, p.local));
}
if let Some(h) = &p.content_hash {
self.by_hash.insert(h.clone(), Some(p.id));
}
}
}
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
use std::sync::atomic::Ordering::Relaxed;
self.look_up_library();
let opts = self.opts.clone();
let mode = self.mode;
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
@@ -1479,6 +1532,7 @@ impl Session {
stages: None,
view_cache: None,
display: None,
preview_cut: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
@@ -1494,6 +1548,10 @@ impl Session {
}
}
#[cfg(test)]
#[path = "import/tests_known.rs"]
mod tests_known;
#[cfg(test)]
mod prepared_tests {
use super::*;
+134
View File
@@ -0,0 +1,134 @@
//! What a scan or an import knows of the library: worked out for the files it meets, and not on
//! the session's thread. A library can hold millions of photos; looking at one folder must not
//! cost a table of them all before the first file is read.
use super::*;
use lightcraft_catalog::{Catalog, Op};
/// A library of `folders` folders of three photos each, the third a copy (by content) of a
/// photo in the folder before.
fn library(folders: usize) -> Catalog {
let mut c = Catalog::new();
for folder in 0..folders {
for (n, name) in ["a.jpg", "b.jpg", "c.jpg"].into_iter().enumerate() {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "JPEG", 6, 4, "2026-01-01");
p.content_hash = Some(format!("{:032x}", if n == 2 { folder.saturating_sub(1) * 3 } else { folder * 3 + n }));
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
}
c
}
/// Given a library of many folders, when a scan meets the files of one of them (and one the
/// library does not have), then what it knows of the library is those files' photos, each as the
/// library has it, and nothing of the other folders.
#[test]
fn a_scan_knows_the_library_photos_of_the_files_it_found() {
let c = library(100);
let files: Vec<String> = ["/pics/007/a.jpg", "/pics/007/b.jpg", "/pics/007/new.jpg", "/pics/007//c.jpg"].map(String::from).to_vec();
let known = library_files(&c, &files);
assert_eq!(known.len(), 3, "the three the library has (a path is compared as a path, not as text)");
let (id, as_found) = known.get(Path::new("/pics/007/b.jpg")).expect("b");
let p = c.photo(*id).unwrap();
assert_eq!((as_found.path.as_str(), as_found.existing, as_found.duplicate.as_deref()), ("/pics/007/b.jpg", Some(id.0), Some("path")));
assert_eq!((as_found.format.as_str(), as_found.width, as_found.height, as_found.file_size), (p.format.as_str(), p.width, p.height, p.file_size));
assert!(library_files(&c, &[]).is_empty());
// two photos of one file (a virtual copy): the later one is the one named, as a table of the
// whole library named it
let mut c = c;
let copy = c.alloc_photo_id();
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(copy, Source::File { path: "/pics/007/a.jpg".into() }, "a.jpg", "JPEG", 6, 4, "2026-01-02")) })
.unwrap();
assert_eq!(library_files(&c, &files).get(Path::new("/pics/007/a.jpg")).map(|(id, _)| *id), Some(copy));
}
/// Given a library and a folder on disk that holds a file the library has by its path, one it
/// has by its content under another name, and a new one, when the folder is scanned (the whole
/// scan, as the import review runs it), then each is found for what it is.
#[test]
fn a_scan_finds_what_the_library_has_by_path_and_by_content() {
let dir = std::env::temp_dir().join(format!("lc-known-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
for name in ["here.jpg", "copy.jpg", "new.jpg"] {
std::fs::write(dir.join(name), name.as_bytes()).unwrap();
}
let mut s = Session::new();
s.catalog = library(20);
let here = s.catalog.alloc_photo_id();
s.catalog
.apply(Op::AddPhoto { photo: Box::new(Photo::new(here, Source::File { path: at("here.jpg") }, "here.jpg", "JPEG", 6, 4, "2026-01-01")) })
.unwrap();
// (the probe: every file is a picture whose content is its name; `copy.jpg` has the content of a library photo)
let had = s.catalog.photos().find(|p| p.file_name == "b.jpg").map(|p| (p.id, p.content_hash.clone().unwrap())).unwrap();
let shared = had.1.clone();
s.media.file_probe = Some(std::sync::Arc::new(move |path: &str| {
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let content_hash = Some(if name == "copy.jpg" { shared.clone() } else { format!("hash-of-{name}") });
Ok(ProbeInfo { format: "JPEG".into(), width: 6, height: 4, file_size: 8, content_hash, ..Default::default() })
}));
let found = scan(&mut s, &[dir.to_string_lossy().to_string()]);
let of = |name: &str| found.iter().find(|c| c.name == name).map(|c| (c.duplicate.as_deref(), c.existing)).unwrap();
assert_eq!(of("here.jpg"), (Some("path"), Some(here.0)));
assert_eq!(of("copy.jpg"), (Some("content"), Some(had.0.0)));
assert_eq!(of("new.jpg"), (None, None));
let _ = std::fs::remove_dir_all(&dir);
}
/// Given the hashes of the files a scan probed, when the library is asked who has them, then it
/// answers for those hashes only; and where two photos have one content, the same one of them
/// as a table of the whole library would name (the later one).
#[test]
fn a_scan_knows_who_has_the_contents_it_probed() {
let c = library(100);
// (the content of /pics/006/a.jpg, which /pics/007/c.jpg has too; and one nobody has)
let shared = format!("{:032x}", 6 * 3);
let nobody = format!("{:032x}", 999_999);
let known = library_hashes(&c, [shared.as_str(), nobody.as_str()].into_iter());
assert_eq!(known.len(), 1);
let whole: HashMap<String, PhotoId> = c.photos().filter_map(|p| p.content_hash.clone().map(|h| (h, p.id))).collect();
assert_eq!(known.get(&shared), whole.get(&shared));
assert!(matches!(&c.photo(known[&shared]).unwrap().source, Source::File { path } if path == "/pics/007/c.jpg"));
}
/// Given a session with a library, when an import is set up, then no table of the library is
/// made on the spot (that is the session's thread); it is made when the first files are
/// prepared, and then finds a file the library has by its path and one it has by its content.
#[test]
fn an_import_looks_the_library_up_when_it_prepares_files() {
let mut s = Session::new();
s.catalog = library(50);
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
assert!(job.by_path.is_empty() && job.by_hash.is_empty() && job.library.is_some(), "nothing is looked up yet");
let out = job.prepare_files(vec!["/pics/007/a.jpg".into()], &std::sync::atomic::AtomicBool::new(false));
assert!(job.library.is_none(), "looked up once");
assert_eq!((job.by_path.len(), job.by_hash.len()), (150, 100));
assert!(matches!(out.items.as_slice(), [PreparedItem::Duplicate { reason: "path", existing: Some(_), .. }]), "found by its path");
// what the options said when the import was set up decides, whatever is made of them after
let id = s.catalog.photos().next().unwrap().id;
s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted: OnDeleted::Fresh, ..ImportOptions::default() }).unwrap();
job.opts.on_deleted = OnDeleted::Skip;
job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
assert_eq!(job.by_path.len(), 149, "the trashed photo was passed over, as set up");
}
/// Given a photo in Recently Deleted and an import that replaces such photos with a fresh
/// import, when the library is looked up, then that photo is no duplicate (as before the lookup
/// moved to the first files).
#[test]
fn a_trashed_photo_is_not_looked_up_when_imports_replace_it() {
let mut s = Session::new();
s.catalog = library(2);
// (a photo whose content is its own: nobody else in this library has it)
let id = s.catalog.photos().find(|p| p.file_name == "b.jpg").unwrap().id;
s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
for (on_deleted, expected) in [(OnDeleted::Fresh, 5), (OnDeleted::Skip, 6)] {
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted, ..ImportOptions::default() }).unwrap();
job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
assert_eq!(job.by_path.len(), expected, "{on_deleted:?}");
let its_content = s.catalog.photo(id).and_then(|p| p.content_hash.clone()).unwrap();
assert_eq!(job.by_hash.contains_key(&its_content), on_deleted == OnDeleted::Skip, "{on_deleted:?}");
}
}
+3 -2
View File
@@ -223,11 +223,12 @@ pub(crate) fn rollback(p: &Placed) {
}
/// Remove a placed photo's source (its catalog record is committed and saved): only when the
/// destination is still there with the same size. Then its sidecars, unless another photo still
/// destination is still there with the same bytes. Then its sidecars, unless another photo still
/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why).
pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
// compare the bytes now: either file may have changed since the copy was verified
let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) {
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(),
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && same_bytes(&p.src, &p.dst).unwrap_or(false),
_ => false,
};
if !same {
+13
View File
@@ -29,8 +29,10 @@ pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
mod errors;
pub mod export;
pub mod face_download;
pub mod face_tiles;
#[cfg(not(target_arch = "wasm32"))]
mod faces_index;
#[cfg(not(target_arch = "wasm32"))]
@@ -68,6 +70,7 @@ pub mod walk;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use errors::{AiModel, ErrorKind};
pub use fonts::{CRAFT_FONTS, CraftFont};
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
@@ -98,6 +101,13 @@ pub enum EngineError {
/// to import with `library.importLightroom`, when one was found.
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
/// The work was cancelled (by the person, an agent or a newer request): not a failure.
#[error("{0}")]
Cancelled(String),
/// The feature needs `model`, which is not installed (or still downloading): a frontend
/// offers its download (terms first) on [`ErrorKind::ModelMissing`], not on the message.
#[error("{msg}")]
ModelMissing { model: AiModel, msg: String },
#[error("{0}")]
Other(String),
}
@@ -1001,6 +1011,9 @@ mod tests_face_models;
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_recognize;
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_tiles;
#[cfg(test)]
mod tests_filesystem_history;
#[cfg(test)]
mod tests_folders;
+29 -7
View File
@@ -24,6 +24,12 @@ fn error(message: impl Into<String>) -> crate::EngineError {
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
/// The import was cancelled ([`crate::ErrorKind::Cancelled`]: not a failure). The message is the
/// one it always had, so control-channel and MCP clients see the same `error` text.
fn cancelled() -> crate::EngineError {
crate::EngineError::Cancelled(error("Lightroom import cancelled").to_string())
}
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
if archive_dir.is_some() && archive_path.is_none() {
@@ -232,13 +238,14 @@ impl LightroomJob {
/// Read/probe/stage all source data off the owner thread.
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
let mut data =
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
// (reading stops with a message when `cancel` is set: the flag says it was cancelled)
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done)
.map_err(|e| if cancel.load(Ordering::Relaxed) { cancelled() } else { error(e) })?;
lightroom_catalog::validate_for_job(&data).map_err(error)?;
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
let paths: Vec<String> = data
.photos
@@ -259,7 +266,7 @@ impl LightroomJob {
let normal = self.import.prepare(&paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
@@ -282,7 +289,7 @@ impl LightroomJob {
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
if prepared.cancelled {
prepared.normal.rollback();
return Err(error("Lightroom import cancelled"));
return Err(cancelled());
}
if !prepared.token.matches(s) {
prepared.normal.rollback();
@@ -435,11 +442,26 @@ mod tests {
let mut session = crate::Session::new();
let before = session.catalog.to_snapshot();
let prepared = cancelled_prepared(&session);
assert!(commit_prepared(&mut session, prepared).is_err());
let Err(e) = commit_prepared(&mut session, prepared) else { panic!("a cancelled import commits nothing") };
assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
assert_eq!(session.catalog.to_snapshot(), before);
assert!(session.undo.is_empty());
}
/// Cancelling while the catalog is read is [`crate::ErrorKind::Cancelled`] whatever the message
/// says, and a failure that is not a cancellation is not one, even when its text says "cancelled".
#[test]
fn a_cancelled_read_is_cancelled_by_kind() {
let mut session = crate::Session::new();
let mut job = LightroomJob::new(&mut session, PathBuf::from("/nonexistent/cancelled.lrcat"), false).unwrap();
let Err(e) = job.prepare(&AtomicBool::new(true)) else { panic!("cancelled before it started") };
assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
// the `error` text clients see is unchanged
assert_eq!(e.to_string(), "invalid parameters for `library.importLightroom`: Lightroom import cancelled");
let Err(e) = job.prepare(&AtomicBool::new(false)) else { panic!("no such catalog") };
assert_ne!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
}
#[test]
fn library_identity_rejects_late_completion_after_reopen() {
let mut session = crate::Session::new();
+31 -1
View File
@@ -395,8 +395,10 @@ fn validate_header_fields(header: &[u8], page_size: usize) -> Result<usize, Stri
if usable < 480 {
return Err("SQLite usable page size is below 480 bytes".into());
}
// 0 = no schema yet (file format §1.3.10): a catalog whose tables are all still in its WAL
// has that in the main file, and the WAL's own page 1 is checked again with the overlay
let schema_format = be_u32(header, 44)?;
if !(1..=4).contains(&schema_format) {
if !(0..=4).contains(&schema_format) {
return Err("unsupported SQLite schema format".into());
}
Ok(reserved)
@@ -941,6 +943,34 @@ mod tests {
assert_eq!(table_alias, Some(0));
}
/// A database file with no schema yet has schema format 0 in its header (file format
/// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one
/// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema
/// format" before the WAL was looked at. It opens, with no tables; other formats stay refused.
#[test]
fn schema_format_zero_is_an_empty_database() {
let header = |format: u32| {
let mut main = vec![0u8; 512];
main[..16].copy_from_slice(b"SQLite format 3\0");
main[16..18].copy_from_slice(&512u16.to_be_bytes());
main[18] = 2;
main[19] = 2;
main[21] = 64;
main[22] = 32;
main[23] = 32;
main[28..32].copy_from_slice(&1u32.to_be_bytes());
main[44..48].copy_from_slice(&format.to_be_bytes());
main[56..60].copy_from_slice(&1u32.to_be_bytes());
main[100] = 0x0d;
main[105..107].copy_from_slice(&512u16.to_be_bytes());
main
};
let db = Database::open_with_wal(header(0), &[]).unwrap();
assert!(db.live_tables().unwrap().is_empty());
assert!(Database::open_with_wal(header(4), &[]).is_ok());
assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format"));
}
#[test]
fn unsupported_unrelated_schema_entries_are_skipped() {
let mut main = vec![0u8; 2 * 512];
+68 -6
View File
@@ -67,6 +67,17 @@ pub const RENDER_CACHE_VERSION: u64 = 62;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the
/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size
/// in between would render (and cache) every face again.
pub const FACE_EDGE_STEP: usize = 16;
/// The edge a face tile asked to be `edge` pixels across is rendered at: the next
/// [`FACE_EDGE_STEP`], 16..=512.
pub fn face_edge(edge: usize) -> usize {
edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge())
}
pub fn thumb_bucket(long_edge: usize) -> usize {
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
}
@@ -741,6 +752,9 @@ pub struct RenderJob {
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option<Arc<crate::display::Display>>,
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
/// ([`crate::Session::face_job`]; `None` for every other job).
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
}
pub struct RenderResult {
@@ -820,6 +834,7 @@ impl RenderJob {
self.settings = Arc::new(s);
self.cache = None;
self.view_cache = None;
self.preview_cut = None;
self
}
@@ -887,6 +902,26 @@ impl RenderJob {
display: None,
};
}
// a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough
// (the sensor data is never read for it), else rendered from `source` below
if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) {
if let Some((cache, key)) = &self.cache {
cache.put_at(self.cache_generation, *key, Arc::new(image.clone()));
}
let histogram = Histogram::of_srgb8(&image);
return RenderResult {
request_id: self.request_id,
source_key: self.source_key,
photo: self.photo,
level: self.level,
key: self.key,
rendered: Ok(Rendered { image, histogram, deep: None }),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
};
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
@@ -1192,6 +1227,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
preview_cut: None,
})
}
@@ -1243,13 +1279,19 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
preview_cut: None,
})
}
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
/// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size
/// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's
/// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the
/// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source
/// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the
/// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows
/// the content: a later launch shows the tile without reading the original.
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
@@ -1267,12 +1309,13 @@ impl crate::Session {
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
let mut settings = (*p.develop).clone();
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
let edge = face_edge(edge);
// pixels along the long edge that leave `edge` across the crop
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
let level = SourceLevel::for_size(needed as usize);
let source = self.media.source_ref(&p, level);
let ck = Self::variant_key(&p, &settings, edge);
let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize);
Some(RenderJob {
request_id: 0,
cache_generation: self.media.rendered.generation(),
@@ -1289,9 +1332,26 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
preview_cut,
})
}
/// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an
/// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as
/// imported, with a preview loader installed. `rect` is the square around the face, `edge` the
/// tile, `needed` the source's long edge the develop render would ask for.
fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option<crate::face_tiles::PreviewCut> {
let (path, loader) = self.embedded_of(p)?;
if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0
{
return None;
}
let want = needed.max(edge).max(1);
let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize;
let aspect = f64::from(p.width) / f64::from(p.height);
Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge })
}
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
Hasher128::new()
@@ -1427,8 +1487,10 @@ impl crate::Session {
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
/// other photos (render them directly).
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job`
/// itself: the grid queues no render behind an unedited raw's quick thumbnail
/// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the
/// cell empty (issue #1448). `None` for other photos (render them directly).
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option<QuickJob> {
let p = self.catalog.photo(job.photo)?.clone();
let (path, l) = self.embedded_of(&p)?;
@@ -1439,7 +1501,7 @@ impl crate::Session {
key: job.key,
cached: job.cache.clone().into_iter().collect(),
embedded: Some((path, l, edge)),
small: None,
small: Some(Box::new(job.clone())),
})
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn default_budget() -> usize {
}
/// Physical memory (bytes), when the platform tells us without native calls.
fn total_ram() -> Option<usize> {
pub(crate) fn total_ram() -> Option<usize> {
#[cfg(target_os = "linux")]
{
let s = std::fs::read_to_string("/proc/meminfo").ok()?;
+6 -2
View File
@@ -130,11 +130,14 @@ impl MergeJob {
return Err("cancelled".into());
}
let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s);
// the reference frame's lens corrections (HDR); panoramas carry none
let mut lens = Vec::new();
let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind {
MergeKind::Hdr { hdr } => {
let frames = self.load(false, progress, (0.0, 0.3))?;
let p = sub(0.3, 0.9);
let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
let mut r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
lens = std::mem::take(&mut r.lens);
let info = json!({
"reference": self.sources[r.reference].0.0,
"ev": r.ev, "evExif": r.ev_exif,
@@ -183,6 +186,7 @@ impl MergeJob {
orientation: r.orientation,
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
lens: Vec::new(),
};
f.orient();
merged.push(f);
@@ -214,7 +218,7 @@ impl MergeJob {
metadata.caption = Some(format!("Merged from {}", names.join(", ")));
}
metadata.software = Some("LightCraft Photo Merge".into());
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?;
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples, &lens).map_err(err)?;
info["width"] = json!(image.width);
info["height"] = json!(image.height);
let preview = match self.preview {
+64 -13
View File
@@ -28,28 +28,45 @@ pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mp
// ------------------------------------------------------------------------------------- zip
/// The most a preset zip entry may hold unpacked (a "DNG preset" photo is the largest kind, tens
/// of MB): its stated size is the file's word, so a small archive could otherwise ask for 8 GB.
pub const MAX_ZIP_ENTRY: usize = 256 << 20;
/// The most a whole preset zip may hold unpacked.
pub const MAX_ZIP_TOTAL: usize = 1 << 30;
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
/// directories, macOS resource forks and hidden files are skipped.
/// directories, macOS resource forks and hidden files are skipped. An entry that unpacks past
/// [`MAX_ZIP_ENTRY`], or an archive past [`MAX_ZIP_TOTAL`], is an error (a zip bomb would make
/// the allocation fail, which ends the app).
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
read_zip_limited(bytes, MAX_ZIP_ENTRY, MAX_ZIP_TOTAL)
}
fn read_zip_limited(bytes: &[u8], max_entry: usize, max_total: usize) -> Result<Vec<(String, Vec<u8>)>, String> {
let u16_at = |o: usize| bytes.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
let u32_at = |o: usize| bytes.get(o..o.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
// offsets from the file, added up without overflowing (a 32-bit build reads them too)
let add = |a: usize, b: usize| a.checked_add(b).ok_or("bad zip");
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
let tail = bytes.len().saturating_sub(65_557);
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
let count = u16_at(eocd + 10).ok_or("bad zip")?;
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
let mut out = Vec::new();
let mut total = 0usize;
for _ in 0..count {
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
return Err("bad zip central directory".into());
}
let method = u16_at(at + 10).ok_or("bad zip")?;
let csize = u32_at(at + 20).ok_or("bad zip")?;
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
let local = u32_at(at + 42).ok_or("bad zip")?;
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
at += 46 + nlen + xlen + clen;
let method = u16_at(add(at, 10)?).ok_or("bad zip")?;
let csize = u32_at(add(at, 20)?).ok_or("bad zip")?;
let usize_ = u32_at(add(at, 24)?).ok_or("bad zip")?;
let (nlen, xlen, clen) =
(u16_at(add(at, 28)?).ok_or("bad zip")?, u16_at(add(at, 30)?).ok_or("bad zip")?, u16_at(add(at, 32)?).ok_or("bad zip")?);
let local = u32_at(add(at, 42)?).ok_or("bad zip")?;
let name_at = add(at, 46)?;
let name = String::from_utf8_lossy(bytes.get(name_at..add(name_at, nlen)?).ok_or("bad zip")?).replace('\\', "/");
at = add(add(add(name_at, nlen)?, xlen)?, clen)?;
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
if skip {
continue;
@@ -57,13 +74,20 @@ pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
return Err(format!("{name}: bad local header"));
}
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
let data_at = add(add(add(local, 30)?, u16_at(add(local, 26)?).ok_or("bad zip")?)?, u16_at(add(local, 28)?).ok_or("bad zip")?)?;
let data = bytes.get(data_at..add(data_at, csize)?).ok_or_else(|| format!("{name}: truncated"))?;
let left = max_total.saturating_sub(total);
let limit = max_entry.min(left);
let too_big = || format!("{name}: unpacks to more than {} MB, too much for a preset", limit >> 20);
let content = match method {
0 if data.len() > limit => return Err(too_big()),
0 => data.to_vec(),
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1).saturating_mul(2).saturating_add(1024).min(limit)).map_err(
|e| if matches!(e.status, miniz_oxide::inflate::TINFLStatus::HasMoreOutput) { too_big() } else { format!("{name}: {e:?}") },
)?,
m => return Err(format!("{name}: unsupported compression method {m}")),
};
total = total.saturating_add(content.len());
out.push((name, content));
}
Ok(out)
@@ -695,6 +719,33 @@ text]], z = ZSTR "loc" }"#,
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
}
#[test]
fn zip_bombs_are_refused() {
// an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named
let zeros = vec![0u8; 3 << 20];
let z = zip(&[("Pack/huge.xmp", &zeros)], true);
assert!(z.len() < 64 << 10, "a small archive: {} bytes", z.len());
let e = read_zip_limited(&z, 1 << 20, 1 << 30).unwrap_err();
assert!(e.contains("Pack/huge.xmp") && e.contains("more than 1 MB"), "{e}");
// the whole archive counts too
let two = zip(&[("a.xmp", &zeros[..1 << 20]), ("b.xmp", &zeros[..1 << 20])], true);
assert!(read_zip_limited(&two, 2 << 20, (3 << 20) / 2).is_err());
assert_eq!(read_zip_limited(&two, 2 << 20, 3 << 20).unwrap().len(), 2);
// and stored entries
let stored = zip(&[("s.xmp", &zeros[..2 << 20])], false);
assert!(read_zip_limited(&stored, 1 << 20, 1 << 30).is_err());
// the size an entry states (here 4 GB) no longer sets how much may be unpacked
let mut lying = zip(&[("a.xmp", b"<x:xmpmeta/>")], true);
let cd = lying.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
lying[cd + 24..cd + 28].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(read_zip(&lying).unwrap()[0].1, b"<x:xmpmeta/>");
// offsets near the top of a u32 are an error, not an overflow
let mut far = zip(&[("a.xmp", b"x")], false);
let cd = far.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
far[cd + 42..cd + 46].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(read_zip(&far).is_err());
}
#[test]
fn zip_bundles_with_folders_as_groups() {
for deflate in [false, true] {
+11 -2
View File
@@ -20,6 +20,8 @@ pub struct Status {
pub file: String,
/// The last download's error (cleared when a new one starts).
pub error: Option<String>,
/// The last download was cancelled (its `error` says so in words): decide on this, not on the text.
pub cancelled: bool,
/// The last download finished and the files are in place.
pub finished: bool,
}
@@ -28,6 +30,7 @@ pub struct Status {
struct Text {
file: String,
error: Option<String>,
cancelled: bool,
}
#[derive(Default)]
@@ -68,15 +71,17 @@ impl Downloader {
{
last.file.clone_from(&t.file);
last.error.clone_from(&t.error);
last.cancelled = t.cancelled;
}
// (only the session's thread locks `last`)
let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
let (file, error, cancelled) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone(), l.cancelled)).unwrap_or_default();
Status {
running: s.running.load(Ordering::SeqCst),
done: s.done.load(Ordering::SeqCst),
total: s.total.load(Ordering::SeqCst),
file,
error,
cancelled,
finished: s.finished.load(Ordering::SeqCst),
}
}
@@ -135,6 +140,7 @@ impl Downloader {
};
let r =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));
let error = match r {
Ok(Ok(())) => {
s.finished.store(true, Ordering::SeqCst);
@@ -147,7 +153,10 @@ impl Downloader {
if let Some(e) = &error {
log::warn!("SAM 3 download: {e}");
}
lock(&s.text).error = error;
let mut text = lock(&s.text);
text.cancelled = cancelled;
text.error = error;
drop(text);
drop(guard);
});
match spawned {
+31 -12
View File
@@ -35,7 +35,7 @@ pub const MODEL_BYTES: u64 = 3_439_938_512;
/// The model's licence (not LightCraft's): shown before downloading.
pub const LICENSE_NAME: &str = "SAM License (Meta)";
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
/// How errors about a missing model start (the UI offers the download on it).
/// How errors about a missing model start (frontends decide on [`crate::ErrorKind::ModelMissing`], not on this text).
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
@@ -111,6 +111,8 @@ pub struct DownloadStatus {
pub total: u64,
pub file: String,
pub error: Option<String>,
/// The last download was cancelled (it resumes next time): decide on this, not on `error`'s text.
pub cancelled: bool,
pub finished: bool,
}
@@ -215,7 +217,15 @@ impl Segmenter {
#[cfg(feature = "sam")]
{
let s = self.download.status();
DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
DownloadStatus {
running: s.running,
done: s.done,
total: s.total,
file: s.file,
error: s.error,
cancelled: s.cancelled,
finished: s.finished,
}
}
#[cfg(not(feature = "sam"))]
DownloadStatus::default()
@@ -234,27 +244,36 @@ impl Segmenter {
/// The model folder, if the model is there; otherwise an error that says what to do.
pub fn model_dir(&self) -> Result<PathBuf, String> {
self.require_model().map_err(|e| e.to_string())
}
/// [`Segmenter::model_dir`] for commands: a model that is not installed (or still downloading) is
/// [`crate::EngineError::ModelMissing`], so a frontend offers the download without reading the message.
pub fn require_model(&self) -> crate::Result<PathBuf> {
let other = |why: &str| crate::EngineError::Other(why.into());
if !Self::AVAILABLE {
return Err("AI masks are not available in this build".into());
return Err(other("AI masks are not available in this build"));
}
#[cfg(feature = "sam")]
if lightcraft_segment::remote::configured().is_some() {
return Ok(PathBuf::new());
}
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
let dir = self.dir.clone().ok_or_else(|| other("no folder is set for the SAM 3 model"))?;
if self.installed() {
return Ok(dir);
}
let d = self.download_status();
if d.running {
let msg = if d.running {
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
}
Err(format!(
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
MODEL_BYTES as f64 / 1e9,
dir.display()
))
format!("{NOT_INSTALLED} yet: it is downloading ({pct} %).")
} else {
format!(
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
MODEL_BYTES as f64 / 1e9,
dir.display()
)
};
Err(crate::EngineError::ModelMissing { model: crate::AiModel::Sam3, msg })
}
/// Start downloading the model (on a background thread; see `segment.model.download`).
+86 -13
View File
@@ -450,6 +450,33 @@ fn stem_key(original: &str) -> String {
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
}
/// Where a plain path is cut to make its [`stem_key`]: at the dot of its extension, which is
/// what `with_extension` replaces. A plain path is ASCII and ends in its file's name with an
/// extension, which is what a library's paths are; `None` for any other shape.
fn plain_cut(original: &str) -> Option<usize> {
let path = Path::new(original);
let (name, stem, ext) = (path.file_name()?.to_str()?, path.file_stem()?.to_str()?, path.extension()?.to_str()?);
// (the path ends in its file's name, as it stands: no `/.` or separator after it; and the
// name has a stem and an extension of its own, which `..jpg` and `a.` have not)
let plain = original.is_ascii() && original.ends_with(name) && !ext.is_empty() && !stem.is_empty() && stem != "." && stem != "..";
let cut = original.len().checked_sub(ext.len().saturating_add(1))?;
(plain && original.as_bytes().get(cut) == Some(&b'.')).then_some(cut)
}
/// [`stem_key`] written into `key`, which is reused: nothing is allocated for a plain path
/// ([`plain_cut`]). Any other shape goes the long way.
fn stem_key_into(key: &mut String, original: &str) {
key.clear();
match plain_cut(original) {
Some(cut) => {
key.push_str(original.get(..cut).unwrap_or_default());
key.make_ascii_lowercase();
key.push_str(".xmp");
}
None => key.push_str(&stem_key(original)),
}
}
fn file_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } => Some(path),
@@ -490,8 +517,11 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
impl StemOwners {
/// The owners of every shared stem in the library, each file's key made the long way: what
/// [`StemOwners::among`] must agree with for the photos it is asked about (this is how the
/// owners were worked out before, for every write).
#[cfg(test)]
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
// per stem: (raw first, then file name, then id) of the best candidate; and how many
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
@@ -505,6 +535,39 @@ impl StemOwners {
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
}
/// The owners of the stems photos `ids` map to: all that naming those photos needs. The
/// library is passed over once, and only files of those stems are looked at closer, so a
/// sidecar written for one photo does not cost a table of the whole library.
pub(crate) fn among(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> StemOwners {
let wanted: std::collections::HashSet<String> =
ids.iter().filter_map(|id| cat.photo(*id)).filter_map(|p| file_path(p)).map(stem_key).collect();
if wanted.is_empty() {
return StemOwners(HashMap::new());
}
Self::where_wanted(cat, &wanted)
}
/// The owners of the shared stems in `wanted`.
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
// per stem: (raw first, then file name, then id) of the best candidate; and how many
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
let mut key = String::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
stem_key_into(&mut key, path);
if !wanted.contains(key.as_str()) {
continue;
}
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
(e.0, e.1) = (rank, p.id);
}
}
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
}
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
match (naming, file_path(p)) {
@@ -514,33 +577,39 @@ impl StemOwners {
}
}
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
let owners = (naming == SidecarNaming::Stem).then(|| StemOwners::among(cat, ids));
ids.iter()
.map(|id| match (cat.photo(*id), &owners) {
(Some(p), Some(o)) => o.naming(p, naming),
_ => naming,
})
.collect()
}
impl Session {
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
/// sidecar — a raw first, else the first by file name. The others use Full naming
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
/// (per photo that is a pass over the whole catalog).
/// [`Session::sidecar_naming`] for many photos, their stems' owners worked out once (per
/// photo that is a pass over the whole catalog).
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
ids.iter()
.map(|id| match (self.catalog.photo(*id), &owners) {
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
_ => self.xmp.naming,
})
.collect()
namings_in(&self.catalog, self.xmp.naming, ids)
}
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
match self.catalog.photo(id) {
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::among(&self.catalog, &[id]).naming(p, self.xmp.naming),
_ => self.xmp.naming,
}
}
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
pub fn save_sidecar(&mut self, id: PhotoId) -> Result<SidecarSaved> {
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
self.save_sidecar_with(id, &StemOwners::among(&self.catalog, &[id]))
}
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
@@ -642,7 +711,7 @@ impl Session {
if ids.is_empty() {
return;
}
let owners = StemOwners::of(&self.catalog);
let owners = StemOwners::among(&self.catalog, &ids);
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
&& let Err(e) = self.save_sidecar_with(id, &owners)
@@ -764,3 +833,7 @@ mod tests_repair_companions;
#[cfg(test)]
#[path = "sidecar/tests_repair_provenance.rs"]
mod tests_repair_provenance;
#[cfg(test)]
#[path = "sidecar/tests_stem_owners.rs"]
mod tests_stem_owners;
@@ -0,0 +1,163 @@
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
//!
//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
//! that is synchronized, must not cost a table of them all.
use super::*;
use lightcraft_catalog::{Catalog, Op, Photo, Source};
/// Paths that try the key: case in folders, names and endings, no ending, a dot file, endings
/// inside folder names, non-ASCII letters (one that lower-cases to ASCII: the Kelvin sign).
const PATHS: [&str; 26] = [
"/pics/trip/IMG_1.CR3",
"/pics/trip/IMG_1.JPG",
"/pics/trip/img_1.jpeg",
"/Pics/Trip/IMG_1.tif",
"/pics/trip/IMG_2.JPG",
"/pics/trip/IMG_3.CR3",
"/pics/trip/IMG_3.cr3.JPG",
"/pics/trip/IMG_3.cr3",
"/pics/other/IMG_1.JPG",
"/pics/other/IMG_1",
"/pics/other/.hidden",
"/pics/other/.hidden.jpg",
"/pics/v1.2/a.b.JPG",
"/pics/v1.2/a.b.cr3",
"/pics/v1.2/a",
"/pics/straße/Größe.JPG",
"/pics/STRASSE/GRÖSSE.cr3",
"/pics/straße/größe.CR3",
"/pics/kelvin/\u{212a}1.JPG",
"/pics/kelvin/k1.cr3",
"/pics/ΟΔΟΣ/ΟΔΟΣ.JPG",
"/pics/οδος/οδος.CR3",
// (names that are all dots before their ending: the ending is no extension to replace)
"/pics/dots/..cr3",
"/pics/dots/..jpg",
"/pics/dots/a./.",
"/pics/dots/a.jpg",
];
fn library() -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for path in PATHS {
let id = c.alloc_photo_id();
let name = path.rsplit('/').next().unwrap_or(path);
let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01");
if path.to_lowercase().ends_with("cr3") {
p.kind = MediaKind::Raw;
}
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
// a virtual copy of the first (it owns nothing) and a photo that is no file
let copy = c.alloc_photo_id();
let mut p = Photo::new(copy, Source::File { path: PATHS[0].to_string() }, "IMG_1.CR3", "X", 6, 4, "2026-01-01");
p.copy_of = Some(ids[0]);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(copy);
let demo = c.alloc_photo_id();
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(demo, Source::Demo { scene: 1 }, "demo", "X", 6, 4, "2026-01-01")) }).unwrap();
ids.push(demo);
(c, ids)
}
fn namings(owners: &StemOwners, c: &Catalog, ids: &[PhotoId]) -> Vec<SidecarNaming> {
ids.iter().map(|id| owners.naming(c.photo(*id).unwrap(), SidecarNaming::Stem)).collect()
}
/// Given a library where files share stems in every way a name can, when the owners are worked
/// out for some of its photos only, then each of those photos is named exactly as the table of
/// the whole library names it: whichever photos are asked about, alone or together.
#[test]
fn owners_among_some_photos_name_them_as_the_whole_library_does() {
let (c, ids) = library();
let whole = StemOwners::of(&c);
let expected = namings(&whole, &c, &ids);
assert!(expected.contains(&SidecarNaming::Full) && expected.contains(&SidecarNaming::Stem), "both cases are there: {expected:?}");
// all at once
assert_eq!(namings(&StemOwners::among(&c, &ids), &c, &ids), expected);
// one at a time, and every pair
for (i, a) in ids.iter().enumerate() {
assert_eq!(namings(&StemOwners::among(&c, &[*a]), &c, &[*a]), [expected[i]], "{:?}", PATHS.get(i));
for (j, b) in ids.iter().enumerate() {
assert_eq!(namings(&StemOwners::among(&c, &[*a, *b]), &c, &[*a, *b]), [expected[i], expected[j]]);
}
}
// an id that is not in the library is passed over
assert_eq!(StemOwners::among(&c, &[PhotoId(9_999)]).0.len(), 0);
}
/// Given a library of many folders, when the owners are worked out for the photos of one, then
/// the table holds that folder's shared stems and nothing of the others: what it costs follows
/// the photos asked about, not the library.
#[test]
fn owners_among_some_photos_hold_nothing_of_the_rest() {
let mut c = Catalog::new();
let mut first = Vec::new();
for folder in 0..200 {
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "X", 6, 4, "2026-01-01");
if name.ends_with("CR3") {
p.kind = MediaKind::Raw;
}
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
if folder == 0 {
first.push(id);
}
}
}
assert_eq!(StemOwners::of(&c).0.len(), 200, "one shared stem a folder");
let some = StemOwners::among(&c, &first);
assert_eq!(some.0.len(), 1, "the one shared stem of the folder asked about");
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
}
/// The key written into a buffer (no allocation for a plain path) is the key: for every path
/// above, for shapes a path from a file can have, and for every string of up to six of the
/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
/// not ASCII).
#[test]
fn the_key_in_a_buffer_is_the_key() {
let odd = [
"",
"/",
"a",
"a.",
".a",
"..a",
"...a",
"a.b",
"a/b.c/",
"a/b.c//",
"a/.b",
"a/..b",
"a/b.",
"a/b..c",
"a./.",
"a\\b.c",
"C:\\x\\y.JPG",
"a/b.c/.",
"a/b.c/..",
"x.JPG ",
"É.JPG",
];
let mut buf = String::new();
for path in PATHS.iter().chain(&odd) {
stem_key_into(&mut buf, path);
assert_eq!(buf, stem_key(path), "{path:?}");
}
let letters = ['a', 'B', '.', '/', '\\', 'é'];
let mut tried = 0usize;
let mut todo: Vec<String> = vec![String::new()];
while let Some(path) = todo.pop() {
stem_key_into(&mut buf, &path);
assert_eq!(buf, stem_key(&path), "{path:?}");
tried += 1;
if path.chars().count() < 6 {
todo.extend(letters.iter().map(|c| format!("{path}{c}")));
}
}
assert_eq!(tried, (0..=6).map(|n| 6usize.pow(n)).sum::<usize>());
}
+17 -9
View File
@@ -74,10 +74,14 @@ impl SyncProgress {
pub struct SyncInput {
folder: String,
scan: ScanInput,
/// The library's photos in the folder, each with how its sidecar is named.
photos: Vec<(Arc<Photo>, SidecarNaming)>,
/// For the colour label names a sidecar may use.
labels: Catalog,
/// The library's photos in the folder.
ids: Vec<PhotoId>,
/// How the library names sidecars; how each photo's is named is worked out by the scan (a
/// pass over the library, which the session's thread does not wait for).
naming: SidecarNaming,
/// The library as it is now (the one the scan looks files up in): for those photos, their
/// sidecars' names and the colour label names a sidecar may use.
labels: Arc<Catalog>,
}
/// What changed in a folder since it came into the library.
@@ -176,10 +180,9 @@ impl SyncInput {
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
}
let namings = s.sidecar_namings(&ids);
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
let labels = scan.library();
Ok(SyncInput { folder: path.to_string(), scan, ids, naming: s.xmp.naming, labels })
}
}
@@ -190,11 +193,16 @@ impl SyncInput {
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
/// is set.
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
let SyncInput { folder, scan, photos, labels } = input;
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
let SyncInput { folder, scan, ids, naming, labels } = input;
let seen: Vec<Arc<Photo>> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
}
// (how each photo's sidecar is named: a pass over the library, made here and not while the
// session waits, and not at all for a folder that is not there)
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
+308
View File
@@ -402,6 +402,48 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() {
assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);
}
/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's
/// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a
/// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear
/// neighbour past isize::MAX). Such a photo renders, quickly.
#[test]
fn degenerate_crops_render_with_spots_and_masks() {
for rect in [[0.5, 0.5, 0.5, 0.5], [0.0, 0.0, 1e-9, 1.0]] {
let mut s = demo();
let id = s.active().unwrap();
s.execute("spot.add", &json!({"points": [[0.5, 0.5]], "size": 0.2, "mode": "heal"})).unwrap();
s.execute("mask.add", &json!({"kind": "radial", "rx": 0.3, "ry": 0.3})).unwrap();
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap();
let t = std::time::Instant::now();
let r = s.render_now(id, 700, 450).unwrap();
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
assert!(t.elapsed().as_secs_f64() < 60.0, "{rect:?}: {:?}", t.elapsed());
}
}
/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
#[test]
fn mask_move_with_extreme_deltas() {
let mut s = demo();
for kind in ["linear", "radial", "brush"] {
s.execute("mask.add", &json!({"kind": kind})).unwrap();
}
let ids = |s: &Session| active_dev(s).masks.iter().map(|m| m.id).collect::<Vec<_>>();
let [a, b, c] = ids(&s)[..] else { panic!("{:?}", ids(&s)) };
s.execute("mask.move", &json!({"id": b, "delta": i64::MAX})).unwrap();
assert_eq!(ids(&s), [a, c, b], "to the bottom");
s.execute("mask.move", &json!({"id": b, "delta": i64::MIN})).unwrap();
assert_eq!(ids(&s), [b, a, c], "to the top");
s.execute("mask.move", &json!({"id": c, "to": i64::MIN})).unwrap();
assert_eq!(ids(&s), [c, b, a]);
s.execute("mask.move", &json!({"id": c, "to": i64::MAX})).unwrap();
assert_eq!(ids(&s), [b, a, c]);
}
#[test]
fn masks_reorder_and_duplicate_and_invert() {
let mut s = demo();
@@ -1178,3 +1220,269 @@ fn hdr_edits_render_with_their_hdr_histogram() {
assert!(r.histogram.above_sdr() > 0.0);
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
}
/// Found by fuzzing catalog snapshots: an album filed under one id but naming its own folder's
/// listed itself inside itself, and `albums.list` recursed until the stack overflowed (an abort:
/// no guard catches it). Loading repairs such a catalog (`Catalog::repair`); one that skipped
/// that still lists, the loop cut off.
#[test]
fn albums_list_survives_a_catalog_that_loops() {
let mut s = Session::with_demo();
let f = s.execute("album.create", &json!({"name": "F", "folder": true})).unwrap()["id"].as_u64().unwrap();
let a = s.execute("album.create", &json!({"name": "A", "parent": f})).unwrap()["id"].as_u64().unwrap();
let mut v: serde_json::Value = serde_json::from_str(&s.catalog.to_snapshot()).unwrap();
v["albums"][a.to_string()]["id"] = f.into();
// as read, without the repair a load does
s.catalog = serde_json::from_value(v).unwrap();
let r = s.execute("albums.list", &json!({})).unwrap();
assert!(r.to_string().contains("\"F\""), "{r}");
}
#[test]
fn develop_paste_validates_groups_and_rejects_malformed_with_zero_mutations() {
let mut s = demo();
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
let photo_a = ids[0];
let photo_b = ids[1];
// Photo A has exposure = 1.0, vibrance = 30.0
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap();
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
// 1. Invalid group names are rejected
for invalid_group in ["bogusGroup", "LIGHT", "Exposure", "unknown"] {
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": [invalid_group]})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }));
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
}
// 2. Incorrect JSON types are rejected
for invalid_param in [
json!("light"), // string instead of sequence
json!(42), // number
json!(true), // boolean
json!({"name": "light"}), // object
json!([123]), // array of non-string
json!(["light", 456]), // mixed array with invalid type
json!([true]), // array of bool
] {
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": invalid_param})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }));
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
}
// 3. Valid groups remain supported: pasting only "light" does not paste "color"
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": ["light"]})).unwrap();
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
assert_eq!(dev_after_light.light.exposure, 1.0);
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
// Reset photo B
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
// 4. Omitted groups preserves default behavior (all copied groups pasted)
s.execute("develop.paste", &json!({"ids": [photo_b.0]})).unwrap();
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
assert_eq!(dev_after_omitted.light.exposure, 1.0);
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
// Reset photo B
s.execute("develop.reset", &json!({})).unwrap();
// 5. Explicit null behaves as omitted
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": null})).unwrap();
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
assert_eq!(dev_after_null.light.exposure, 1.0);
assert_eq!(dev_after_null.color.vibrance, 30.0);
}
#[test]
fn develop_sync_validates_groups_and_rejects_malformed_with_zero_mutations() {
let mut s = demo();
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
let photo_a = ids[0];
let photo_b = ids[1];
// Setup: Photo A has exposure = 1.0, vibrance = 30.0
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
// Select Photo A (active) and Photo B
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
// 1. Invalid group names are rejected
for invalid_group in ["bogusGroup", "LIGHT", "WhiteBalance", "invalid_name"] {
let err = s.execute("develop.sync", &json!({"groups": [invalid_group]})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }));
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
}
// 2. Incorrect JSON types are rejected
for invalid_param in [
json!("light"), // string instead of sequence
json!(100), // number
json!(false), // boolean
json!({"groups": "light"}), // object
json!([999]), // array of non-string
json!(["color", false]), // mixed array with invalid type
json!([null]), // array with null
] {
let err = s.execute("develop.sync", &json!({"groups": invalid_param})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }));
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
}
// 3. Valid groups remain supported: syncing only "light" does not sync "color"
s.execute("develop.sync", &json!({"groups": ["light"]})).unwrap();
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
assert_eq!(dev_after_light.light.exposure, 1.0);
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
// Reset photo B
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
// Reselect Photo A (active) and Photo B
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
// 4. Omitted groups preserves default behavior (all default copy groups synced)
s.execute("develop.sync", &json!({})).unwrap();
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
assert_eq!(dev_after_omitted.light.exposure, 1.0);
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
// Reset photo B
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
// Reselect Photo A (active) and Photo B
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
// 5. Explicit null behaves as omitted
s.execute("develop.sync", &json!({"groups": null})).unwrap();
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
assert_eq!(dev_after_null.light.exposure, 1.0);
assert_eq!(dev_after_null.color.vibrance, 30.0);
}
#[test]
fn develop_copy_and_paste_previous_validate_groups() {
let mut s = demo();
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
let photo_a = ids[0];
let photo_b = ids[1];
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
s.execute("develop.set", &json!({"values": {"light.exposure": 1.5}})).unwrap();
// develop.copy rejects invalid group
let copy_err = s.execute("develop.copy", &json!({"groups": ["notAGroup"]})).unwrap_err();
assert!(matches!(copy_err, crate::EngineError::BadParams { .. }));
// develop.copy accepts valid groups
s.execute("develop.copy", &json!({"groups": ["light"]})).unwrap();
// Switch active photo to photo_b so photo_a becomes previous_active
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
// develop.pastePrevious rejects invalid group
let prev_err = s.execute("develop.pastePrevious", &json!({"groups": ["badGroup"]})).unwrap_err();
assert!(matches!(prev_err, crate::EngineError::BadParams { .. }));
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
// develop.pastePrevious accepts valid group
s.execute("develop.pastePrevious", &json!({"groups": ["light"]})).unwrap();
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 1.5);
}
#[test]
fn presets_validate_groups_on_create_and_update() {
let mut s = demo();
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 25}})).unwrap();
let initial_preset_count = s.presets.len();
// 1. preset.create rejects invalid group names and incorrect JSON types without creating a preset
for invalid_groups in [json!(["bogusGroup"]), json!("light"), json!(123), json!([true]), json!(["light", 99])] {
let err = s.execute("preset.create", &json!({"name": "Test", "groups": invalid_groups})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }));
assert_eq!(s.presets.len(), initial_preset_count);
}
// 2. preset.create with valid groups creates a preset with only those groups
let res = s.execute("preset.create", &json!({"name": "ValidPreset", "groups": ["light"]})).unwrap();
let pid = res["id"].as_str().unwrap().to_string();
assert_eq!(s.presets.len(), initial_preset_count + 1);
let p = s.presets.iter().find(|p| p.id == pid).unwrap();
assert!(p.settings.get("light").is_some());
assert!(p.settings.get("color").is_none());
// 3. preset.create with omitted groups uses default copy groups
let res_omitted = s.execute("preset.create", &json!({"name": "DefaultPreset"})).unwrap();
let pid_omitted = res_omitted["id"].as_str().unwrap().to_string();
let p_omitted = s.presets.iter().find(|p| p.id == pid_omitted).unwrap();
assert!(p_omitted.settings.get("light").is_some());
assert!(p_omitted.settings.get("color").is_some());
// Change develop settings
s.execute("develop.set", &json!({"values": {"light.exposure": 2.0, "color.vibrance": 50}})).unwrap();
let preset_before_update = s.presets.iter().find(|p| p.id == pid).unwrap().clone();
// 4. preset.update rejects invalid group names and incorrect JSON types without modifying the preset
for invalid_groups in [json!(["bogusGroup"]), json!("color"), json!(999), json!([false]), json!(["color", "bad"])] {
let err = s.execute("preset.update", &json!({"id": pid, "groups": invalid_groups})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }));
let current = s.presets.iter().find(|p| p.id == pid).unwrap();
assert_eq!(*current, preset_before_update);
}
// 5. preset.update with valid groups updates specified groups
s.execute("preset.update", &json!({"id": pid, "groups": ["light", "color"]})).unwrap();
let p_after_valid = s.presets.iter().find(|p| p.id == pid).unwrap();
assert_eq!(p_after_valid.settings["light"]["exposure"], 2.0);
assert_eq!(p_after_valid.settings["color"]["vibrance"], 50.0);
// 6. preset.update with omitted groups updates existing preset keys
s.execute("develop.set", &json!({"values": {"light.exposure": -1.0, "color.vibrance": -20}})).unwrap();
s.execute("preset.update", &json!({"id": pid})).unwrap();
let p_after_omitted = s.presets.iter().find(|p| p.id == pid).unwrap();
assert_eq!(p_after_omitted.settings["light"]["exposure"], -1.0);
assert_eq!(p_after_omitted.settings["color"]["vibrance"], -20.0);
}
/// A Heal or Remove spot whose size (from a sidecar, which isn't held to the slider's range) is far past the photo
/// made the render's radius infinite: its heal blur and the source it reads went with it, overflowing the blur's box
/// radii and the bilinear sampler (issue #1519). Such spots render, quickly, as a spot over the whole frame.
#[test]
fn oversized_spots_render() {
use lightcraft_develop::{Spot, SpotMode};
for size in [10.0, 1e30, f64::MAX, f64::INFINITY, f64::NAN] {
for mode in [SpotMode::Heal, SpotMode::Remove, SpotMode::Clone] {
let mut s = demo();
let id = s.active().unwrap();
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
dd.spots.push(Spot { mode, points: vec![lightcraft_geom::Point::new(0.5, 0.5)], size, ..Default::default() });
s.set_develop(id, dd, "Remove").unwrap();
let t = std::time::Instant::now();
let r = s.render_now(id, 300, 200).unwrap();
assert!(r.image.width >= 1 && r.image.height >= 1, "{size} {mode:?}");
assert!(t.elapsed().as_secs_f64() < 60.0, "{size} {mode:?}: {:?}", t.elapsed());
}
}
}
+17 -3
View File
@@ -205,10 +205,24 @@ fn detect_without_the_model_says_where_to_get_it() {
let d = temp("nodetector");
let mut s = Session::with_demo();
// no folder for models at all, then a folder that has none yet
assert!(s.execute("faces.detect", &json!({})).is_err());
let e = s.execute("faces.detect", &json!({})).unwrap_err();
assert_ne!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "nothing to download into: {e}");
s.face_models_dir = Some(d.join("models"));
let e = s.execute("faces.detect", &json!({})).unwrap_err().to_string();
assert!(e.contains("Settings > Faces"), "{e}");
let e = s.execute("faces.detect", &json!({})).unwrap_err();
// the app offers the download on the kind (#1046), the person reads the message
assert_eq!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "{e}");
assert!(
e.to_string().starts_with("invalid parameters for `faces.detect`: ") && e.to_string().contains("Settings > Faces"),
"the `error` text clients see is unchanged: {e}"
);
// a detector that is there but unusable also points to Settings > Faces, and is not a missing one
let home = d.join("models").join(lightcraft_faces::known::YUNET_ID);
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("model.onnx"), b"not a model").unwrap();
std::fs::write(home.join("face-model.json"), b"{}").unwrap();
let e = s.execute("faces.detect", &json!({})).unwrap_err();
assert!(e.to_string().contains("Settings > Faces"), "{e}");
assert_eq!(e.kind(), crate::ErrorKind::BadParams, "{e}");
let _ = std::fs::remove_dir_all(&d);
}
+275
View File
@@ -0,0 +1,275 @@
//! Face tiles from the embedded preview, kept in the thumbnail cache (issue #1440).
//!
//! The fixture is a TIFF-family raw with two embedded JPEG previews (a small flat red one and a
//! larger picture) and a sensor strip, as #1020's planned-read tests build them. Counting wrappers
//! around the session's file loader (the path that reads the whole original and develops the raw)
//! and its preview loader say which source a face tile came from.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use lightcraft_catalog::PhotoId;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
use lightcraft_geom::Rect;
use lightcraft_raster::Rgba8;
use serde_json::json;
use crate::Session;
const W: u32 = 600;
const H: u32 = 400;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-face-tiles-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The big preview's pixel at (x, y): smooth ramps and a soft disc, so a resampled square of it
/// compares well between two resamplers.
fn big_pixel(x: u32, y: u32) -> [u8; 3] {
let (dx, dy) = (x as f64 - 300.0, y as f64 - 200.0);
let disc = (-(dx * dx + dy * dy) / (2.0 * 40.0 * 40.0)).exp();
[(40.0 + 150.0 * x as f64 / W as f64) as u8, (60.0 + 120.0 * y as f64 / H as f64) as u8, (50.0 + 160.0 * disc) as u8]
}
fn jpeg(w: u32, h: u32, px: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
let rgb: Vec<u8> = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| px(x, y)).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
encode_jpeg(&img, 95, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap()
}
/// A raw (DNG-tagged) whose IFD0 is a small flat red preview, IFD1 the big picture and IFD2 a CFA
/// "sensor" strip of `tail` bytes. The sensor data can't be developed, so the import describes the
/// photo by its largest preview (600 × 400) and the develop path renders from it, after reading
/// the whole file.
fn raw_with_two_previews(tail: usize) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let preview = |w: u32, h: u32, bytes: Vec<u8>, first: bool| {
let mut ifd = IfdBuilder::new();
if first {
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
}
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![bytes] });
ifd
};
let small = preview(120, 80, jpeg(120, 80, |_, _| [200, 40, 40]), true);
let big = preview(W, H, jpeg(W, H, big_pixel), false);
let mut raw = IfdBuilder::new();
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
TiffWriter::default().write(&[small, big, raw]).unwrap()
}
/// How often each loader ran.
#[derive(Clone, Default)]
struct Loads {
/// The original read whole and developed (for a raw: its sensor data decoded).
files: Arc<AtomicUsize>,
/// The embedded preview read (planned read: header and preview only).
previews: Arc<AtomicUsize>,
}
impl Loads {
fn get(&self) -> (usize, usize) {
(self.files.load(Ordering::SeqCst), self.previews.load(Ordering::SeqCst))
}
}
/// Count the session's loads from now on.
fn count_loads(s: &mut Session) -> Loads {
let loads = Loads::default();
let file = s.media.file_loader.clone().unwrap();
let n = loads.files.clone();
s.media.file_loader = Some(Arc::new(move |p: &str, e: usize| {
n.fetch_add(1, Ordering::SeqCst);
file(p, e)
}));
let preview = s.media.preview_loader.clone().unwrap();
let n = loads.previews.clone();
s.media.preview_loader = Some(Arc::new(move |p: &str, e: usize| {
n.fetch_add(1, Ordering::SeqCst);
preview(p, e)
}));
loads
}
/// A session on the library in `dir` (created on first use).
fn open(dir: &Path) -> Session {
let mut s = Session::new().with_fs();
s.open_library(dir.join("library"), false).unwrap();
s
}
/// A library holding the fixture: (dir, session, the photo, the file).
fn setup(tag: &str) -> (PathBuf, Session, PhotoId, PathBuf) {
let dir = temp_dir(tag);
let file = dir.join("faces.dng");
std::fs::write(&file, raw_with_two_previews(400_000)).unwrap();
let mut s = open(&dir);
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let p = s.catalog.photos().next().unwrap();
assert_eq!((p.kind, p.width, p.height), (lightcraft_catalog::MediaKind::Raw, W, H), "described by its largest preview");
let id = p.id;
(dir, s, id, file)
}
/// A face of a tenth of the frame, centred: a 64 px tile needs a 377 px source, which the 600 px
/// preview has.
const FACE: Rect = Rect { x0: 0.45, y0: 0.45, x1: 0.55, y1: 0.55 };
/// A face of 2 %: a 64 px tile would need a 1883 px source; the preview is under ¾ of that.
const TINY_FACE: Rect = Rect { x0: 0.49, y0: 0.49, x1: 0.51, y1: 0.51 };
fn tile(s: &mut Session, id: PhotoId, face: Rect, edge: usize) -> Result<Rgba8, String> {
s.face_job(id, face, edge).unwrap().run().rendered.map(|r| r.image)
}
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
assert_eq!((a.width, a.height), (b.width, b.height));
let sum: u64 = a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| u64::from(p[c].abs_diff(q[c])))).sum();
sum as f64 / (a.data.len() * 3) as f64
}
/// An unedited raw's face tile is cut from its largest embedded preview: the original is never read
/// whole nor its raw developed, and the tile matches the develop path's at tile size.
#[test]
fn an_unedited_raws_face_comes_from_its_largest_preview() {
let (dir, mut s, id, file) = setup("preview");
// (the preview loader's planned read: the header and the preview, not the sensor strip)
let (_, _, read) = crate::files::preview_window(&file.to_string_lossy(), usize::MAX).unwrap().unwrap();
assert!(read.fraction() < 0.5, "{read:?}");
let loads = count_loads(&mut s);
let from_preview = tile(&mut s, id, FACE, 64).unwrap();
assert_eq!((from_preview.width, from_preview.height), (64, 64));
assert_eq!(loads.get(), (0, 1), "one preview read, no whole-file read or raw decode");
// the big preview, not the small flat red one
let centre = from_preview.data[32 * 64 + 32];
assert!(centre[2] > 150 && centre[0] < 150, "the face's centre is the big preview's disc: {centre:?}");
// the develop path (no preview loader: what every face used before), from the memory cache's reach
s.media.preview_loader = None;
let mut job = s.face_job(id, FACE, 64).unwrap();
job.cache = None;
let developed = job.run().rendered.unwrap().image;
assert_eq!(loads.files.load(Ordering::SeqCst), 1, "the develop path reads the original");
let d = mean_abs_diff(&from_preview, &developed);
eprintln!("mean difference preview/develop: {d:.3}");
assert!(d < 4.0, "the preview's tile looks like the developed one: mean difference {d:.2} of 255");
let _ = std::fs::remove_dir_all(dir);
}
/// A preview under the soft limit (¾ of the pixels the tile needs across the face) leaves the tile to
/// the develop path; so does an edited photo, whose look the camera's JPEG doesn't have.
#[test]
fn a_preview_too_small_or_an_edited_photo_falls_back_to_the_develop_path() {
let (dir, mut s, id, _) = setup("fallback");
let loads = count_loads(&mut s);
let img = tile(&mut s, id, TINY_FACE, 64).unwrap();
assert_eq!((img.width, img.height), (64, 64));
assert_eq!(loads.get(), (1, 1), "the preview was tried, then the original developed");
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
let edited = tile(&mut s, id, FACE, 64).unwrap();
assert_eq!(loads.get(), (2, 1), "an edited photo's face is developed; its preview isn't read");
let _ = std::fs::remove_dir_all(dir);
drop(edited);
}
/// Rendered tiles are kept in the library's thumbnail cache: a new session (the next launch) shows
/// them without reading the original, from the preview or the develop path alike; and a size
/// between two steps reuses the tile of its step.
#[test]
fn face_tiles_are_kept_on_disk_for_the_next_launch() {
let (dir, mut s, id, _) = setup("disk");
let first = tile(&mut s, id, FACE, 64).unwrap();
let _ = tile(&mut s, id, TINY_FACE, 64).unwrap();
drop(s);
let mut s = open(&dir);
let loads = count_loads(&mut s);
let again = tile(&mut s, id, FACE, 64).unwrap();
let tiny = tile(&mut s, id, TINY_FACE, 64).unwrap();
assert_eq!(loads.get(), (0, 0), "both tiles come from the disk cache: the original is not touched");
assert_eq!((again.width, tiny.width), (64, 64));
assert!(mean_abs_diff(&first, &again) < 3.0, "the cached tile is the rendered one (as a JPEG)");
// 60 px asks for the 64 px step: the same tile, no new render
assert_eq!(s.face_job(id, FACE, 60).unwrap().key, s.face_job(id, FACE, 64).unwrap().key);
let _ = tile(&mut s, id, FACE, 60).unwrap();
assert_eq!(loads.get(), (0, 0));
let _ = std::fs::remove_dir_all(dir);
}
/// What can go wrong never panics: a preview loader that fails or answers nonsense, a damaged
/// preview, face boxes at the edge or degenerate, an offline original, a damaged cache entry.
#[test]
fn face_tiles_never_crash() {
let (dir, mut s, id, file) = setup("crash");
// a damaged cache entry: dropped, and the tile rendered again (from the preview)
let job = s.face_job(id, FACE, 64).unwrap();
let key = job.cache.as_ref().unwrap().1.to_string();
assert!(job.run().rendered.is_ok());
let thumbs = s.library.as_ref().unwrap().thumbs_dir();
drop(s);
let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
assert!(entry.exists(), "the tile was written to the thumbnail cache");
std::fs::write(&entry, b"not a jpeg").unwrap();
let mut s = open(&dir);
let loads = count_loads(&mut s);
assert_eq!(tile(&mut s, id, FACE, 64).unwrap().width, 64);
assert_eq!(loads.get(), (0, 1), "rendered again from the preview");
// face boxes at the corners, oversized, empty, not numbers: a tile or an error, never a panic
for face in [
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
Rect { x0: f64::NEG_INFINITY, y0: f64::NAN, x1: f64::NAN, y1: f64::NAN },
] {
for edge in [0, 1, 64, usize::MAX] {
let img = tile(&mut s, id, face, edge).unwrap_or_else(|e| panic!("{face:?} at {edge}: {e}"));
assert_eq!(img.width, img.height, "{face:?} at {edge}");
}
}
// a preview loader that answers nonsense or nothing: the develop path renders the tile
let id2 = id;
for answer in [None, Some(Rgba8 { width: W as usize, height: H as usize, data: Vec::new() }), Some(Rgba8::new(1, 1))] {
s.media.preview_loader = Some(Arc::new(move |_: &str, _: usize| answer.clone()));
let mut job = s.face_job(id2, FACE, 64).unwrap();
job.cache = None;
assert_eq!(job.run().rendered.unwrap().image.width, 64);
}
// a damaged preview in the file: no tile from it, and no panic either way
s.media.preview_loader = Some(crate::files::fs_preview_loader());
let mut bytes = std::fs::read(&file).unwrap();
let n = bytes.len();
for b in bytes.iter_mut().take(n - 400_000).skip(1000) {
*b = 0x5a;
}
std::fs::write(&file, &bytes).unwrap();
let mut job = s.face_job(id, FACE, 64).unwrap();
job.cache = None;
let _ = job.run();
// the original gone (an offline drive): an error, no panic
std::fs::remove_file(&file).unwrap();
let mut job = s.face_job(id, FACE, 64).unwrap();
job.cache = None;
assert!(job.run().rendered.is_err());
let _ = std::fs::remove_dir_all(dir);
}
+27
View File
@@ -503,3 +503,30 @@ fn copies_run_side_by_side_with_the_same_outcome() {
assert_eq!(first.file_name, "IMG_1.png");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn finish_keeps_the_original_when_the_copy_changed_to_the_same_length() {
use crate::import_move::{finish, place};
let base = temp_dir("finish-recheck");
let (src, dest) = (base.join("card/IMG_1.jpg"), base.join("dest"));
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
std::fs::write(&src, b"original bytes").unwrap();
inject(Fault::NoLink);
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
// unchanged copy: the original goes
assert!(finish(&placed).is_ok());
assert!(!src.exists());
// a copy replaced by other bytes of the same length: the original stays
std::fs::write(&src, b"original bytes").unwrap();
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
std::fs::write(&placed.dst, b"different byte").unwrap();
assert!(finish(&placed).is_err());
assert_eq!(std::fs::read(&src).unwrap(), b"original bytes");
// an original edited meanwhile to other bytes of the same length: it stays too
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
std::fs::write(&src, b"edited bytes!").unwrap();
assert!(finish(&placed).is_err());
assert_eq!(std::fs::read(&src).unwrap(), b"edited bytes!");
inject(Fault::None);
let _ = std::fs::remove_dir_all(&base);
}
+51
View File
@@ -67,6 +67,57 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
let _ = std::fs::remove_dir_all(&dir);
}
/// An HDR merge keeps the reference frame's lens corrections. The sources carry an off-centre `WarpRectilinear` and
/// `FixVignetteRadial` for their active area and have a default crop; the merged DNG must give "Enable Profile
/// Corrections" the same correction as the reference photo, upright and for a vertical bracket (whose DNG keeps the
/// pixels unturned).
#[test]
fn hdr_merge_keeps_the_reference_frames_lens_corrections() {
use lightcraft_geom::{Orientation, Point};
use lightcraft_pipeline::optics::{embedded_vignette_gain, embedded_warp};
use lightcraft_raw::Opcode;
for orientation in [Orientation::Normal, Orientation::Rotate90] {
let dir = temp_dir(&format!("hdr-lens-{orientation:?}"));
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
let mut raw = lightcraft_raw::decode(&b).unwrap();
raw.crop = lightcraft_raw::Rect::new(10, 6, 452, 304);
raw.orientation = orientation;
raw.metadata.orientation = Some(orientation);
raw.opcodes.list3 = vec![
Opcode::WarpRectilinear { planes: vec![[0.96, 0.05, -0.012, 0.002, 0.001, -0.0005]], center: [0.48, 0.53] },
Opcode::FixVignetteRadial { k: [0.35, -0.05, 0.01, 0.0, 0.0], center: [0.48, 0.53] },
];
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.hdr", &json!({"ids": ids})).unwrap();
assert_eq!(r["reference"].as_u64(), Some(ids[1]), "{r}");
let merged = s.catalog.photo(lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap())).unwrap().clone();
let reference = s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().clone();
let expect = if orientation == Orientation::Normal { (452, 304) } else { (304, 452) };
assert_eq!(((merged.width, merged.height), (reference.width, reference.height)), (expect, expect));
let a = reference.embedded_lens.expect("the source's correction");
let b = merged.embedded_lens.expect("the merged DNG carries the reference frame's correction");
let (wa, wb) = (a.warp.unwrap(), b.warp.unwrap());
let (va, vb) = (a.vignette.unwrap(), b.vignette.unwrap());
let (w, h) = (expect.0 as f64, expect.1 as f64);
for (x, y) in [(0.0, 0.0), (w, h), (w, 0.0), (0.0, h), (w * 0.3, h * 0.7), (w * 0.5, h * 0.5)] {
let p = Point::new(x, y);
for ch in 0..3 {
let (pa, pb) = (embedded_warp(&wa, p, ch, w, h), embedded_warp(&wb, p, ch, w, h));
assert!((pa.x - pb.x).abs() < 1e-6 && (pa.y - pb.y).abs() < 1e-6, "{orientation:?} {p:?}: {pa:?} vs {pb:?}");
}
let (ga, gb) = (embedded_vignette_gain(&va, p, w, h), embedded_vignette_gain(&vb, p, w, h));
assert!((ga - gb).abs() < 1e-9, "{orientation:?} {p:?}: {ga} vs {gb}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
#[test]
+28 -1
View File
@@ -115,6 +115,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
}
let st = s.segmenter.download_status();
assert!(!st.finished);
assert!(!st.cancelled, "a failure is not a cancel");
let e = st.error.unwrap();
assert!(e.contains("not found"), "{e}");
assert!(!s.segmenter.installed());
@@ -272,9 +273,35 @@ fn a_running_model_download_shows_in_activity_and_cancels() {
assert!(t.elapsed() < Duration::from_secs(10), "the download never stopped");
std::thread::sleep(Duration::from_millis(10));
}
let e = s.segmenter.download_status().error.unwrap_or_default();
let st = s.segmenter.download_status();
assert!(st.cancelled, "the app tells a cancel by the flag (#1046): {st:?}");
let e = st.error.unwrap_or_default();
assert!(e.contains("cancelled"), "{e}");
assert!(!s.segmenter.installed());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&mirrors);
}
/// Frontends decide on the error's kind, not its words (#1046): a missing model is
/// `ModelMissing(Sam3)` whether the app queues requests (background) or a command waits (CLI, MCP);
/// a build without AI masks is not a missing model (there is nothing to download).
#[test]
fn a_missing_model_is_model_missing_by_kind() {
use crate::{AiModel, ErrorKind};
for background in [true, false] {
let mut s = Session::with_demo();
s.segmenter.background = background;
s.segmenter.dir = Some(tmp(&format!("kind-{background}")));
for (cmd, p) in
[("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
{
let e = s.execute(cmd, &p).unwrap_err();
if Segmenter::AVAILABLE {
assert_eq!(e.kind(), ErrorKind::ModelMissing(AiModel::Sam3), "{cmd} (background {background}): {e}");
assert!(e.to_string().starts_with(NOT_INSTALLED), "the message is unchanged: {e}");
} else {
assert_eq!(e.kind(), ErrorKind::Other, "{cmd}: {e}");
}
}
}
}
+3 -2
View File
@@ -20,8 +20,9 @@ fn a_preview_reads_only_the_header_and_the_preview() {
assert!(file_len > 0, "empty file");
let started = Instant::now();
let window =
lightcraft_engine::files::preview_window(&path).expect("the file reads").expect("it carries an embedded preview the header can point at");
let window = lightcraft_engine::files::preview_window(&path, usize::MAX)
.expect("the file reads")
.expect("it carries an embedded preview the header can point at");
let elapsed = started.elapsed();
let (_, preview, read) = window;
+1 -1
View File
@@ -2,6 +2,6 @@
Native model downloads in pure Rust (L0, Apache-2.0): HTTP/1.1 over std sockets and rustls with the RustCrypto provider, without ring, aws-lc, C or assembly build dependencies. The wasm32 crate is empty.
Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host.
Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host. A redirect from https to http is refused.
Tests use local synthetic HTTP servers for redirects, resume, truncation, cancellation, stalls, hashes, size limits and hostile specifications; they make no internet requests.
+10 -1
View File
@@ -110,7 +110,11 @@ impl Url {
pub fn join(&self, location: &str) -> Result<Url, HttpError> {
let l = location.trim();
if l.starts_with("http://") || l.starts_with("https://") {
return Url::parse(l);
let url = Url::parse(l)?;
if self.tls && !url.tls {
return Err(HttpError::BadUrl("redirect from https to http refused".into()));
}
return Ok(url);
}
// another scheme (`ftp:`, `file:`, `data:`…)
if let Some((scheme, _)) = l.split_once(':')
@@ -541,6 +545,11 @@ mod tests {
assert_eq!(u.join("other").unwrap().path, "/x/y/other");
assert_eq!(u.join("//c.example/p").unwrap().host, "c.example");
assert!(u.join("ftp://nope").is_err());
// no downgrade from https to http, however the redirect is written
assert!(u.join("http://a.example/z").is_err());
let plain = Url::parse("http://127.0.0.1:8080/a").unwrap();
assert!(!plain.join("http://127.0.0.1:9/b").unwrap().tls);
assert!(plain.join("https://b.example/z").unwrap().tls);
}
#[test]
+2 -3
View File
@@ -245,10 +245,9 @@ fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (us
/// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way
/// (`lightcraft_raster::blur::gaussian`).
pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf {
if sigma <= 0.3 || w * h == 0 {
let Some(radii) = lightcraft_raster::blur::radii_for(sigma, w, h) else {
return cx.copy(src);
}
let radii = lightcraft_raster::blur::box_radii(sigma);
};
let passes: Vec<(bool, usize)> = radii.iter().map(|r| (true, *r)).chain(radii.iter().map(|r| (false, *r))).filter(|p| p.1 > 0).collect();
if passes.is_empty() {
return cx.copy(src);
+109 -12
View File
@@ -65,22 +65,46 @@ impl Remote {
Err(last)
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
/// Send one request line and read the reply line. On failure the flag says whether the request
/// may have reached the app (written in full); only an unsent request is safe to resend.
fn roundtrip(&mut self, line: &str) -> Result<String, (std::io::Error, bool)> {
// A connection the app already closed (it restarted, or closed it after an error reply) is
// replaced before anything is sent on it, so that case still reconnects without a resend.
if self.conn.as_ref().is_some_and(|(reader, writer)| !reader.buffer().is_empty() || peer_closed(writer)) {
self.conn = None;
}
if self.conn.is_none() {
self.reconnect()?;
self.reconnect().map_err(|e| (e, false))?;
}
let Some((reader, writer)) = self.conn.as_mut() else {
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
return Err((std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"), false));
};
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut data = Vec::with_capacity(line.len() + 1);
data.extend_from_slice(line.as_bytes());
data.push(b'\n');
writer.write_all(&data).map_err(|e| (e, false))?;
writer.flush().map_err(|e| (e, true))?;
let mut reply = String::new();
if reader.read_line(&mut reply)? == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
if reader.read_line(&mut reply).map_err(|e| (e, true))? == 0 {
return Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"), true));
}
Ok(reply)
}
fn unknown_outcome(&self, method: &str, e: &std::io::Error) -> String {
let addr = &self.addr;
format!("lost the LightCraft app at {addr} after sending `{method}` ({e}); the outcome is unknown and it was not resent, check the app")
}
}
/// True when the app has closed `stream` (or it is unusable), checked without blocking or consuming data.
fn peer_closed(stream: &TcpStream) -> bool {
if stream.set_nonblocking(true).is_err() {
return true;
}
let mut byte = [0u8; 1];
let open = matches!(stream.peek(&mut byte), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock);
stream.set_nonblocking(false).is_err() || !open
}
impl Backend for Remote {
@@ -88,16 +112,26 @@ impl Backend for Remote {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
// One retry with a fresh connection (the app may have restarted).
// Retry once with a fresh connection (the app may have restarted), but only when the request
// never reached the app. After a full write the command may have run, so resending it could
// apply a mutation twice (toggle, undo, ...): report the unknown outcome instead.
let reply = match self.roundtrip(&line) {
Ok(r) => r,
Err(_) => {
Err((_, false)) => {
self.conn = None;
self.roundtrip(&line).map_err(|e| {
self.roundtrip(&line).map_err(|(e, sent)| {
self.conn = None;
format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
if sent {
self.unknown_outcome(method, &e)
} else {
format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
}
})?
}
Err((e, true)) => {
self.conn = None;
return Err(self.unknown_outcome(method, &e));
}
};
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
if v.get("ok").and_then(Value::as_bool) == Some(true) {
@@ -115,3 +149,66 @@ impl Backend for Remote {
format!("remote {}", self.addr)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn lost_reply_is_not_resent() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(AtomicUsize::new(0));
let seen2 = seen.clone();
std::thread::spawn(move || {
// Every connection: read the request, count it, drop without replying.
while let Ok((s, _)) = listener.accept() {
let mut line = String::new();
if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 {
seen2.fetch_add(1, Ordering::SeqCst);
}
}
});
let mut remote = Remote::lazy(&addr);
let err = remote.call("engine.execute", json!({})).unwrap_err();
assert!(err.contains("outcome is unknown"), "{err}");
std::thread::sleep(Duration::from_millis(200));
assert_eq!(seen.load(Ordering::SeqCst), 1);
}
#[test]
fn connection_closed_by_app_reconnects_before_sending() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(AtomicUsize::new(0));
let seen2 = seen.clone();
std::thread::spawn(move || {
// Every connection: answer one request, then close (as after an app restart).
while let Ok((s, _)) = listener.accept() {
let mut writer = s.try_clone().unwrap();
let mut line = String::new();
if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 {
seen2.fetch_add(1, Ordering::SeqCst);
writer.write_all(b"{\"ok\": true, \"result\": 7}\n").unwrap();
}
}
});
let mut remote = Remote::lazy(&addr);
assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(7));
std::thread::sleep(Duration::from_millis(200));
assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(7));
assert_eq!(seen.load(Ordering::SeqCst), 2);
}
#[test]
fn unreachable_app_is_reported() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
drop(listener);
let err = Remote::lazy(&addr).call("engine.commands", json!({})).unwrap_err();
assert!(err.contains("not reachable"), "{err}");
}
}
+25 -3
View File
@@ -6,7 +6,7 @@ use lightcraft_geom::Orientation;
use lightcraft_meta::Metadata;
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
use lightcraft_raw::ColorData;
use lightcraft_raw::{ColorData, Opcode};
use crate::{MergeError, Result};
@@ -36,6 +36,10 @@ pub struct Frame {
pub metadata: Metadata,
/// Raw files: the source's `BaselineExposure` (EV), carried into the output.
pub baseline_exposure: f64,
/// Raw files: the lens corrections the source applies (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`,
/// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for
/// other sources and once [`Frame::orient`] has turned the pixels.
pub lens: Vec<Opcode>,
}
impl Frame {
@@ -53,6 +57,7 @@ impl Frame {
orientation: Orientation::Normal,
metadata: Metadata::default(),
baseline_exposure: 0.0,
lens: Vec::new(),
}
}
@@ -63,11 +68,12 @@ impl Frame {
self.image.height
}
/// Apply the display orientation to the pixels.
/// Apply the display orientation to the pixels. The lens corrections, written for the unturned pixels, are dropped.
pub fn orient(&mut self) {
if self.orientation != Orientation::Normal {
self.image = self.image.oriented(self.orientation);
self.orientation = Orientation::Normal;
self.lens.clear();
}
}
}
@@ -120,7 +126,21 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
let exposure = exif_exposure(&metadata);
if lightcraft_raw::probe(bytes).is_some() {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
// geometric lens corrections stay with the develop settings ("profile corrections")
// Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and
// the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps.
// The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional.
let (aw, ah) = (raw.active_area.width, raw.active_area.height);
let crop = raw.develop_crop(aw, ah);
let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3)
.filter(|op| op.is_lens_correction())
.filter_map(|op| op.cropped(aw, ah, crop))
.map(|op| match op {
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false }
}
op => op,
})
.collect();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(color) = colour(&mut raw, bytes) {
raw.color = color;
@@ -146,6 +166,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
orientation: raw.orientation,
metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() },
baseline_exposure: raw.color.baseline_exposure,
lens,
};
if f.exposure.is_none() {
f.exposure = exif_exposure(&f.metadata);
@@ -176,6 +197,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
orientation: Orientation::from_exif(d.orientation),
metadata,
baseline_exposure: 0.0,
lens: Vec::new(),
};
if orient {
f.orient();
+3
View File
@@ -83,6 +83,8 @@ pub struct HdrResult {
pub orientation: lightcraft_geom::Orientation,
pub metadata: lightcraft_meta::Metadata,
pub baseline_exposure: f64,
/// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]).
pub lens: Vec<lightcraft_raw::Opcode>,
}
#[inline]
@@ -277,6 +279,7 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
orientation: rf.orientation,
metadata: rf.metadata.clone(),
baseline_exposure: rf.baseline_exposure,
lens: rf.lens.clone(),
})
}
+9 -5
View File
@@ -8,12 +8,14 @@
//! still camera RGB.
//! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB,
//! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1).
//! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the
//! develop settings' profile corrections find them as they do in the sources.
use lightcraft_color::{D50, D65, Mat3, bradford};
use lightcraft_geom::Orientation;
use lightcraft_meta::Metadata;
use lightcraft_raster::Rgb32f;
use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, OpcodeLists, RawData, RawFormat, RawImage, Rect};
use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, Opcode, OpcodeLists, RawData, RawFormat, RawImage, Rect};
use crate::frame::FrameColor;
use crate::{MergeError, Result};
@@ -59,7 +61,8 @@ pub fn color_data(color: &FrameColor) -> ColorData {
}
}
/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG.
/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG, with `lens` (lens corrections for `img`, not
/// yet turned by `orientation`) as its `OpcodeList3`.
pub fn write_linear_dng(
img: &Rgb32f,
color: &FrameColor,
@@ -67,6 +70,7 @@ pub fn write_linear_dng(
metadata: &Metadata,
baseline_exposure: f64,
samples: DngSamples,
lens: &[Opcode],
) -> Result<Vec<u8>> {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 {
@@ -107,7 +111,7 @@ pub fn write_linear_dng(
color: cd,
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() },
metadata: meta,
};
let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half };
@@ -122,7 +126,7 @@ mod tests {
fn linear_dng_round_trips_through_the_raw_decoder() {
let img = Rgb32f::from_fn(80, 60, |x, y| [x as f32 / 20.0, y as f32 / 30.0, 0.3]);
let color = FrameColor::Linear { to_xyz_d50: bradford(D65, D50).mul(&lightcraft_color::SRGB.to_xyz()) };
let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half).unwrap();
let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half, &[]).unwrap();
let raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (80, 60, 3));
let gain = 2f32.powf(raw.color.baseline_exposure as f32);
@@ -145,7 +149,7 @@ mod tests {
for c in 0..3 {
assert!((red[c] / k - want[c]).abs() < 5e-3, "{red:?} vs {want:?}");
}
let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16).unwrap();
let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16, &[]).unwrap();
assert!(lightcraft_raw::decode(&u16).is_ok());
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result<Vec<Vec<u8>>> {
iso: Some(100),
..Default::default()
};
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16)
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16, &[])
})
.collect()
}
+24 -3
View File
@@ -16,7 +16,7 @@ use crate::optics::{Warp, reorient_lens};
pub const BLANK: [f32; 3] = [0.55, 0.55, 0.55];
/// The geometric frame of a render.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
/// Source (EXIF-oriented) dimensions in pixels.
pub src_w: usize,
@@ -167,10 +167,12 @@ impl Frame {
}
}
/// Aspect ratio (w/h) of the output.
/// Aspect ratio (w/h) of the output. A crop narrower than a source pixel (or of no size, from
/// an agent or a sidecar) counts each side as one pixel, so the aspect stays finite: 0/0 made
/// it NaN, and the loupe laid the photo out at a NaN size.
pub fn aspect(&self) -> f64 {
let r = self.crop.rect_px(self.ow, self.oh);
r.width() / r.height()
r.width().max(1.0) / r.height().max(1.0)
}
/// Size of the (cropped) output at the source's own resolution, in pixels.
@@ -383,6 +385,25 @@ mod tests {
assert!((back.x - 50.0).abs() < 1e-6 && (back.y - 50.0).abs() < 1e-6);
}
/// A crop of no size (or under a pixel wide) gave a NaN or infinite aspect; each side now
/// counts as at least one source pixel.
#[test]
fn degenerate_crops_have_a_finite_aspect() {
let mut s = DevelopSettings::default();
for (rect, want) in [
(Rect::new(0.5, 0.5, 0.5, 0.5), 1.0),
(Rect::new(0.0, 0.0, 1e-9, 1.0), 1.0 / 200.0),
(Rect::new(0.0, 0.5, 1.0, 0.5), 400.0),
(Rect::new(0.25, 0.0, 0.75, 1.0), 1.0),
] {
s.crop.geometry.rect = rect;
let f = Frame::new(400, 200, &s, true);
assert!((f.aspect() - want).abs() < 1e-9, "{rect:?}: {}", f.aspect());
let (w, h) = f.fit(1000, 1000);
assert!((1..=1000).contains(&w) && (1..=1000).contains(&h), "{rect:?}: {w}×{h}");
}
}
#[test]
fn flip_mirrors() {
let mut s = DevelopSettings::default();
+54 -1
View File
@@ -543,12 +543,35 @@ pub(crate) fn prepare(
/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide):
/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds.
pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 {
let mut img = frame.sample(src, proxy_w, proxy_h);
let mut img = Rgb32f::clone(&frame_proxy(src, frame, proxy_w, proxy_h));
white_balance(&mut img, info, s);
let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32;
airlight(&gaussian(&img.map(dark_of), sigma))
}
/// How many airlight proxies [`frame_proxy`] keeps (one per photo and frame being zoomed into; each ≤ 1.8 MB).
const FRAME_PROXIES_KEPT: usize = 4;
/// `frame.sample(src, w, h)`, kept for the last few (source, frame, size): sampling a 64 MP original down to the
/// airlight proxy reads all of it (~190 ms of CPU), and every window of a zoomed view with Dehaze asked for it again,
/// on each pan and slider tick. The proxy is taken before white balance, so a WB change still hits.
fn frame_proxy(src: &Rgb32f, frame: &Frame, w: usize, h: usize) -> Arc<Rgb32f> {
type Entry = (u64, Frame, usize, usize, Arc<Rgb32f>);
static CACHE: std::sync::Mutex<Vec<Entry>> = std::sync::Mutex::new(Vec::new());
let key = crate::optics::fingerprint(src);
let found = |c: &[Entry]| c.iter().find(|e| e.0 == key && e.1 == *frame && (e.2, e.3) == (w, h)).map(|e| e.4.clone());
if let Some(img) = found(&CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)) {
return img;
}
let img = Arc::new(frame.sample(src, w, h));
let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
c.push((key, frame.clone(), w, h, img.clone()));
if c.len() > FRAME_PROXIES_KEPT {
c.remove(0);
}
img
}
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.
pub const AIRLIGHT_STEP: usize = 7;
@@ -600,6 +623,36 @@ mod tests {
assert!((luminance_2020(c) - 0.3).abs() < 0.01);
}
// A windowed render's airlight proxy is kept per (source, frame, size): the airlight is the one sampling anew gives,
// and another source or frame of the same size gets its own, never the proxy kept for the last one
#[test]
fn the_kept_airlight_proxy_matches_a_fresh_one_and_misses_on_another_source_or_frame() {
let (info, s) = (SourceInfo::default(), DevelopSettings::default());
let hazy = Rgb32f::from_fn(300, 200, |x, y| {
let v = 0.05 + 0.6 * x as f32 / 300.0;
[v, v * 0.95, v * (0.8 + 0.2 * y as f32 / 200.0)]
});
let other = Rgb32f::from_fn(300, 200, |x, y| [0.02 + 0.1 * ((x + y) % 7) as f32 / 7.0; 3]);
let fresh = |src: &Rgb32f, frame: &Frame| {
let mut img = frame.sample(src, 48, 32);
white_balance(&mut img, &info, &s);
airlight(&gaussian(&img.map(dark_of), (0.02 * frame.px_per_long(48)).max(1.0) as f32))
};
let frame = crate::frame_for(&hazy, &info, &s, true);
let kept = frame_proxy(&hazy, &frame, 48, 32);
assert!(Arc::ptr_eq(&kept, &frame_proxy(&hazy, &frame, 48, 32)), "the second ask is served from the cache");
assert_eq!(frame_airlight(&hazy, &info, &s, &frame, 48, 32).to_bits(), fresh(&hazy, &frame).to_bits());
assert_eq!(frame_airlight(&hazy, &info, &s, &frame, 48, 32).to_bits(), fresh(&hazy, &frame).to_bits());
// another source of the same size
assert_ne!(fresh(&other, &frame), fresh(&hazy, &frame));
assert_eq!(frame_airlight(&other, &info, &s, &frame, 48, 32).to_bits(), fresh(&other, &frame).to_bits());
// the same source, cropped to its darker half
let mut cropped = frame.clone();
cropped.crop.rect.x1 = 0.5;
assert_ne!(fresh(&hazy, &cropped), fresh(&hazy, &frame));
assert_eq!(frame_airlight(&hazy, &info, &s, &cropped, 48, 32).to_bits(), fresh(&hazy, &cropped).to_bits());
}
#[test]
fn exposure_doubles() {
let mut s = DevelopSettings::default();
+3 -1
View File
@@ -398,7 +398,9 @@ fn box_down(m: &Image<u8>, f: usize) -> Plane {
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5), edges extended.
fn bilinear(p: &Plane, x: f32, y: f32) -> f32 {
let (fx, fy) = (x - 0.5, y - 0.5);
// (held a pixel outside, which reads the same edge: an infinite coordinate reads the edge
// instead of a NaN weight and a neighbour index past isize::MAX)
let (fx, fy) = (x.clamp(-1.0, p.width as f32 + 1.0) - 0.5, y.clamp(-1.0, p.height as f32 + 1.0) - 0.5);
let (x0, y0) = (fx.floor(), fy.floor());
let (tx, ty) = (fx - x0, fy - y0);
let (x0, y0) = (x0 as isize, y0 as isize);
+44 -5
View File
@@ -407,8 +407,9 @@ pub(crate) fn fingerprint(img: &Rgb32f) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ ((img.width as u64) << 32 | img.height as u64);
let n = img.data.len();
for i in 0..512usize {
let p = img.data[(i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)];
for c in p {
// (an empty image has no pixels to sample: its dimensions are the fingerprint)
let Some(p) = img.data.get((i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)) else { break };
for c in *p {
h ^= c.to_bits() as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
@@ -578,9 +579,13 @@ pub fn defringe(img: &mut Rgb32f, s: &DevelopSettings, scale: f64) {
return;
}
let (w, h) = (img.width, img.height);
let max_amt = o.defringe_purple_amount.max(o.defringe_green_amount);
// fringe width in source px grows with the amount (1..7 px), in output px at least one
let radius = ((1.0 + max_amt * 0.3) * scale).round().max(1.0) as usize;
// as far as the sliders go (0–20): an amount from a damaged catalog or sidecar made the
// radius overflow
let max_amt = o.defringe_purple_amount.max(o.defringe_green_amount).min(20.0);
// fringe width in source px grows with the amount (1..7 px), in output px at least one (and
// never wider than the picture)
let radius = ((1.0 + max_amt * 0.3) * scale).round();
let radius = if radius.is_finite() { radius.clamp(1.0, w.max(h).max(1) as f64) as usize } else { 1 };
// perceptual lightness and its local range (dilated contrast)
let lab: Vec<[f32; 3]> = img.data.iter().map(|p| oklab_from_2020([p[0].max(0.0), p[1].max(0.0), p[2].max(0.0)])).collect();
let l = Plane { width: w, height: h, data: lab.iter().map(|p| p[0]).collect() };
@@ -648,6 +653,13 @@ fn min_filter(p: &Plane, r: usize, max: bool) -> Plane {
mod tests {
use super::*;
// An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked)
#[test]
fn an_empty_image_has_a_fingerprint() {
assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0)));
assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0)));
}
/// splitmix64: deterministic pseudo-random numbers for the warp tests.
struct Rng(u64);
@@ -1095,4 +1107,31 @@ mod tests {
assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3);
assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4);
}
/// Found by fuzzing catalog snapshots: a defringe amount far past the sliders' 20 (from a
/// damaged catalog or sidecar) made the filter's radius overflow (`x + r`), which panics.
/// It acts as the sliders' end.
#[test]
fn defringe_amounts_past_the_sliders_act_as_their_end() {
let purple = [0.25f32, 0.05, 0.45];
let scene = Rgb32f::from_fn(40, 20, |x, _| {
if x < 20 {
[0.9; 3]
} else if x == 20 {
purple
} else {
[0.02; 3]
}
});
let at = |amount: f64, scale: f64| {
let mut s = DevelopSettings::default();
s.optics.defringe_purple_amount = amount;
s.optics.defringe_green_amount = amount / 2.0;
let mut img = scene.clone();
defringe(&mut img, &s, scale);
img
};
assert_eq!(at(1e300, 1.0).data, at(20.0, 1.0).data);
assert_eq!(at(f64::MAX, 1e300).data.len(), 800, "a huge scale too");
}
}
+65 -3
View File
@@ -18,10 +18,27 @@ use crate::geometry::Frame;
#[inline]
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
// Zero feather is a hard edge, including when the pixel lies exactly on the radius.
// Dividing 0 by 0 here would give that pixel a NaN alpha, even at zero opacity.
if e0 >= e1 {
return if x < e1 { 0.0 } else { 1.0 };
}
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Most a spot's size counts for, in long edges: a size read from a sidecar isn't held to the slider's range, and one
/// far past the picture (or infinite) made the radius, the blur it heals with and the patch it reads from unbounded
/// (issue #1519). Two long edges cover the whole frame from anywhere in it.
const MAX_SPOT_SIZE: f64 = 2.0;
/// A spot's radius in output pixels, `ppl` output pixels to the long edge: at least a pixel (a size that is not a
/// number too), at most [`MAX_SPOT_SIZE`] long edges.
fn radius_px(size: f64, ppl: f64) -> f64 {
let size = if size.is_nan() { 0.0 } else { size.min(MAX_SPOT_SIZE) };
(size * ppl).max(1.0)
}
/// Mean colour of `img` in a disc/annulus.
fn ring_stats(img: &Rgb32f, c: (f32, f32), r0: f32, r1: f32) -> Option<Vec<[f32; 3]>> {
let mut out = Vec::new();
@@ -99,7 +116,7 @@ pub fn pick_source(
let img = frame.sample(src, w, h);
let (to_out, to_norm) = (frame.norm_to_out(w, h), frame.out_to_norm(w, h));
let t = to_out.apply(*spot.points.first()?);
let r = (spot.size * frame.px_per_long(w)).max(1.0) as f32;
let r = radius_px(spot.size, frame.px_per_long(w)) as f32;
let avoid = avoid.map(|o| {
let (a, b) = (to_out.apply(Point::new(0.0, 0.0)), to_out.apply(o));
((b.x - a.x) as f32, (b.y - a.y) as f32)
@@ -165,7 +182,7 @@ pub fn window_for_reads_checked(
let boxes: Vec<([f64; 4], [f64; 4])> = spots
.iter()
.filter_map(|spot| {
let r = (spot.size * ppl).max(1.0);
let r = radius_px(spot.size, ppl);
let pts: Vec<_> = spot.points.iter().map(|p| to_out.apply(*p)).collect();
let first = pts.first()?;
let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y);
@@ -261,7 +278,7 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
let (w, h) = (img.width, img.height);
let to_out = frame.norm_to_out(w, h);
for spot in spots {
let r = (spot.size * ppl).max(1.0) as f32;
let r = radius_px(spot.size, ppl) as f32;
let pts: Vec<(f32, f32)> = spot
.points
.iter()
@@ -369,6 +386,51 @@ mod tests {
use super::*;
use lightcraft_develop::DevelopSettings;
#[test]
fn hard_edge_spots_at_zero_opacity_leave_every_pixel_unchanged() {
for mode in [SpotMode::Clone, SpotMode::Heal, SpotMode::Remove, SpotMode::Fill] {
for (cx, cy) in [(32.5, 24.5), (0.5, 0.5)] {
let original = Rgb32f::from_fn(64, 48, |x, y| [0.2 + x as f32 / 128.0, 0.3 + y as f32 / 128.0, 0.4]);
let mut img = original.clone();
let frame = Frame::new(64, 48, &DevelopSettings::default(), true);
let spot = Spot {
mode,
points: vec![Point::new(cx / 64.0, cy / 48.0)],
size: 5.0 / 64.0,
feather: 0.0,
opacity: 0.0,
source_offset: Some(Point::new(8.0 / 64.0, 0.0)),
};
apply(&mut img, &[spot], &frame, frame.px_per_long(64));
for (i, (actual, expected)) in img.data.iter().zip(&original.data).enumerate() {
assert_eq!(actual, expected, "zero-opacity {mode:?} at ({cx}, {cy}), pixel {i}");
}
}
}
}
#[test]
fn hard_edge_clone_copies_inside_and_preserves_pixels_on_the_radius() {
for (w, h, cx, cy, radius) in [(64, 48, 32.5, 24.5, 5.0), (128, 96, 64.5, 48.5, 10.0)] {
let original = Rgb32f::from_fn(w, h, |x, y| [0.2 + x as f32 / w as f32, y as f32 / h as f32, 0.4]);
let mut img = original.clone();
let frame = Frame::new(w, h, &DevelopSettings::default(), true);
let spot = Spot {
mode: SpotMode::Clone,
points: vec![Point::new(cx / w as f64, cy / h as f64)],
size: radius / w as f64,
feather: 0.0,
opacity: 100.0,
source_offset: Some(Point::new(0.125, 0.0)),
};
apply(&mut img, &[spot], &frame, frame.px_per_long(w));
let (x, y) = (cx as usize, cy as usize);
assert_eq!(img.get(x, y), original.get(x + w / 8, y), "inside copies the source at {w}px");
assert_eq!(img.get(x + radius as usize, y), original.get(x + radius as usize, y), "radius stays unchanged at {w}px");
assert!(img.data.iter().flatten().all(|v| v.is_finite()));
}
}
#[test]
fn remove_erases_a_dot() {
// flat grey with a gentle gradient and a black dot in the middle
+24
View File
@@ -711,3 +711,27 @@ fn stored_process_numbers_render_with_a_known_process() {
}
}
}
/// A crop a hair wide (or of no size, as a command or a sidecar can set) shown to fit made the
/// tone, clarity and dehaze stages blur with sigmas of millions of pixels (infinite for no size):
/// the box width's square overflowed (a panic in debug builds, a radius of billions of pixels and
/// a hung render in release ones). The blur is held to twice the picture's size: the render is
/// quick and its pixels are numbers.
#[test]
fn sliver_crops_render_quickly_with_wide_stages_on() {
let src = scene();
for (rect, angle) in [([0.0, 0.0, 1e-9, 1.0], 45.0), ([0.5, 0.5, 0.5, 0.5], 0.0), ([0.0, 0.0, 1e-5, 1.0], -30.0)] {
let mut s = DevelopSettings::default();
s.crop.geometry.rect = lightcraft_geom::Rect::new(rect[0], rect[1], rect[2], rect[3]);
s.crop.geometry.angle = angle;
s.effects.clarity = 80.0;
s.effects.dehaze = 50.0;
s.effects.texture = 40.0;
s.light.highlights = -60.0;
s.light.shadows = 60.0;
let t = std::time::Instant::now();
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(280, 180));
assert!(t.elapsed().as_secs_f64() < 20.0, "{rect:?}: {:?}", t.elapsed());
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
}
}
+11 -7
View File
@@ -199,13 +199,18 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>,
}
}
Overlay::AllMasks { view, color, opacity } => {
if let Some(planes) = masks {
for a in planes {
if (a.width, a.height) == (img.width, img.height) {
mask_view(img, a, view, color, opacity);
}
// one view of the union of the masks: painting the planes one after another
// overwrites (White on Black, Image on Black…) or compounds (Color) the earlier ones
let mut union: Option<Plane> = None;
for a in masks.unwrap_or_default().iter().filter(|a| (a.width, a.height) == (img.width, img.height)) {
match &mut union {
None => union = Some(a.clone()),
Some(u) => u.data.iter_mut().zip(&a.data).for_each(|(u, a)| *u = u.max(*a)),
}
}
if let Some(u) = &union {
mask_view(img, u, view, color, opacity);
}
}
}
}
@@ -425,7 +430,6 @@ mod tests {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }],
..Default::default()
});
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 };
let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40);
@@ -435,7 +439,7 @@ mod tests {
// a hidden mask is skipped
s.masks[1].visible = false;
let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted");
assert_eq!(img2.data[in_r], [0, 0, 0, 255], "hidden right mask not painted: black like everything outside");
assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted");
// parts round trip and a stable, distinct cache key
let (k, v) = o.to_parts();
+12 -2
View File
@@ -9,6 +9,7 @@
//! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see
//! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a
//! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98).
//! A shard directory that is a symlink or junction is not followed (issue #1504).
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -65,7 +66,7 @@ impl DiskCache {
pub fn get(&self, key: Hash128) -> Option<Rgba8> {
let p = self.path(key);
let Ok(bytes) = std::fs::read(&p) else {
let Some(bytes) = p.parent().filter(|d| is_real_dir(d)).and_then(|_| std::fs::read(&p).ok()) else {
self.misses.fetch_add(1, Ordering::Relaxed);
return None;
};
@@ -91,7 +92,7 @@ impl DiskCache {
let Some(bytes) = encode_jpeg(img) else { return };
let p = self.path(key);
let Some(parent) = p.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
if std::fs::create_dir_all(parent).is_err() || !is_real_dir(parent) {
return;
}
// Serialize the file replacement and its size accounting. A repeated render of
@@ -124,6 +125,10 @@ impl DiskCache {
if !is_shard_name(sub_name) {
continue;
}
// a shard that is a symlink (or junction) leads outside the cache: never walk it
if !is_real_dir(&sub.path()) {
continue;
}
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
let name = f.file_name();
@@ -173,6 +178,11 @@ impl DiskCache {
}
}
/// Whether `p` is a directory itself, not a symlink or Windows reparse point to one.
fn is_real_dir(p: &Path) -> bool {
std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir())
}
fn is_lower_hex(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
+27
View File
@@ -380,6 +380,33 @@ fn disk_cache_never_touches_foreign_files() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A shard directory that is a symlink to a folder outside the cache is neither listed, cleared,
/// pruned, read nor written (issue #1504).
#[cfg(unix)]
#[test]
fn disk_cache_ignores_symlinked_shard_directories() {
let dir = temp_dir("symlink-shard");
let outside = temp_dir("symlink-outside");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let key = hash_bytes(&[9, 9]);
let hex = key.to_string();
let sentinel = outside.join(format!("{hex}.jpg"));
std::fs::write(&sentinel, vec![7u8; 50_000]).unwrap();
std::os::unix::fs::symlink(&outside, dir.join(&hex[..2])).unwrap();
let d = DiskCache::new(&dir, 10);
assert_eq!(d.size(), 0, "files behind the link are not the cache's");
d.prune();
assert!(sentinel.exists(), "prune followed the link");
assert!(d.get(key).is_none(), "get read through the link");
d.put(key, &gradient(16, 16, 1));
assert_eq!(std::fs::metadata(&sentinel).unwrap().len(), 50_000, "put wrote through the link");
d.clear();
assert!(sentinel.exists(), "clear followed the link");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or
/// after the timeout.
#[test]
+76 -5
View File
@@ -3,8 +3,16 @@
use crate::resample::Pixel;
use crate::{Image, par_rows};
/// Box radii approximating a Gaussian of `sigma` with 3 passes (Kovesi / Wells).
/// The largest blur sigma (pixels) the box radii are made for. A box wider than any image (the
/// encoders' limit is 65535 px) blurs to the same flat result, and a larger sigma made the box
/// width's square overflow (from about 23 170 px: a panic where overflow is checked, a garbage
/// radius of billions of pixels, i.e. a hung render, where it isn't).
pub const MAX_SIGMA: f32 = 65_536.0;
/// Box radii approximating a Gaussian of `sigma` with 3 passes (Kovesi / Wells). A sigma that is
/// not a number counts as 0 (no blur); one above [`MAX_SIGMA`] (infinity too) as `MAX_SIGMA`.
pub fn box_radii(sigma: f32) -> [usize; 3] {
let sigma = if sigma.is_nan() { 0.0 } else { sigma.clamp(0.0, MAX_SIGMA) };
let n = 3.0f32;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i32;
@@ -12,7 +20,8 @@ pub fn box_radii(sigma: f32) -> [usize; 3] {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
// (the square in i64: it passes i32 from a box 46 341 px wide)
let m_ideal = (12.0 * sigma * sigma - n * (i64::from(wl) * i64::from(wl)) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
let m = m_ideal.round() as i32;
std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize)
}
@@ -71,12 +80,24 @@ fn box_v<T: Pixel>(src: &Image<T>, dst: &mut Image<T>, r: usize) {
});
}
/// The box radii of [`gaussian`] on a `w × h` image (the GPU renderer's twin uses them too), or
/// `None` when the blur leaves the image as it is: `sigma` at most 0.3 or not a number, or an
/// empty image. A sigma wider than twice the image's long side counts as that: a crop a hair
/// wide shown to fit asked the tone and dehaze stages for sigmas of millions of pixels (infinite
/// for a crop of no size), which blur no flatter than that but cost boxes that wide on every row.
pub fn radii_for(sigma: f32, w: usize, h: usize) -> Option<[usize; 3]> {
if sigma.is_nan() || sigma <= 0.3 || w == 0 || h == 0 {
return None;
}
let cap = (w.max(h) as f32 * 2.0).max(1.0);
Some(box_radii(sigma.min(cap)))
}
/// Blur with Gaussian `sigma` (pixels). `sigma <= 0.3` returns a copy.
pub fn gaussian<T: Pixel>(img: &Image<T>, sigma: f32) -> Image<T> {
if sigma <= 0.3 || img.is_empty() {
let Some(radii) = radii_for(sigma, img.width, img.height) else {
return img.clone();
}
let radii = box_radii(sigma);
};
let mut a = img.clone();
par_rows(&mut a.data, img.width, |_, row| {
let mut tmp = Vec::with_capacity(row.len());
@@ -97,6 +118,56 @@ pub fn gaussian<T: Pixel>(img: &Image<T>, sigma: f32) -> Image<T> {
#[cfg(test)]
mod tests {
/// The box radii of the formula as it was, for the check below.
fn box_radii_before(sigma: f32) -> [usize; 3] {
let n = 3.0f32;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i32;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
let m = m_ideal.round() as i32;
std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize)
}
/// A blur sigma from a degenerate edit or a deep zoom (found by a UI campaign: a render
/// worker's blur with a huge sigma) squared the box width past i32, which panics in a debug
/// build and gave a radius of billions of pixels in a release one. Huge, infinite and NaN
/// sigmas give bounded radii, and a blur with them is quick and stays finite; every sigma that
/// worked before keeps exactly its radii.
#[test]
fn huge_and_non_finite_sigmas_give_bounded_radii() {
for sigma in [26_800.0, 46_341.0, 1e5, 1e9, f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN, -5.0] {
let r = box_radii(sigma);
assert!(r.iter().all(|&r| r <= MAX_SIGMA as usize * 2), "{sigma}: {r:?}");
}
assert_eq!(box_radii(f32::NAN), [0, 0, 0]);
assert_eq!(box_radii(f32::NEG_INFINITY), [0, 0, 0]);
assert_eq!(box_radii(1e9), box_radii(MAX_SIGMA));
assert_eq!(box_radii(f32::INFINITY), box_radii(MAX_SIGMA));
// on an image, the blur is at most twice its long side; NaN and tiny sigmas don't blur
assert_eq!(radii_for(3.1e7, 1400, 900), Some(box_radii(2800.0)));
assert_eq!(radii_for(f32::INFINITY, 1400, 900), Some(box_radii(2800.0)));
assert_eq!(radii_for(40.0, 1400, 900), Some(box_radii(40.0)), "an ordinary sigma is as asked");
assert_eq!(radii_for(f32::NAN, 1400, 900), None);
assert_eq!(radii_for(0.2, 1400, 900), None);
assert_eq!(radii_for(5.0, 0, 900), None);
let p = crate::Plane { width: 4, height: 3, data: (0..12).map(|i| i as f32).collect() };
let t = std::time::Instant::now();
for sigma in [1e9, f32::INFINITY, f32::NAN] {
let b = gaussian(&p, sigma);
assert!(b.data.iter().all(|v| v.is_finite()), "{sigma}");
}
assert!(t.elapsed().as_secs_f64() < 30.0, "a huge sigma's blur is bounded");
let mut s = 0.0f32;
while s < 23_000.0 {
assert_eq!(box_radii(s), box_radii_before(s), "sigma {s}");
s = s * 1.013 + 0.07;
}
}
use super::*;
#[test]
+17 -2
View File
@@ -206,8 +206,11 @@ impl Rgb32f {
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5).
#[inline]
pub fn sample_bilinear(&self, x: f32, y: f32) -> [f32; 3] {
let fx = x - 0.5;
let fy = y - 0.5;
// a pixel past the edge reads the edge pixel, so a coordinate held a pixel outside gives
// the same sample; an infinite one (a crop of no size's inverse mapping) then reads the
// edge too, instead of a NaN weight and a neighbour index past isize::MAX
let fx = x.clamp(-1.0, self.width as f32 + 1.0) - 0.5;
let fy = y.clamp(-1.0, self.height as f32 + 1.0) - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = fx - x0;
@@ -258,6 +261,18 @@ mod tests {
use super::*;
use lightcraft_geom::Orientation;
/// A sample at an infinite coordinate (the inverse mapping of a crop of no size) cast to
/// isize::MAX, and its right / lower neighbour overflowed (a panic where overflow is checked):
/// the neighbours saturate and the edge pixel comes back.
#[test]
fn bilinear_samples_at_infinite_coordinates_read_the_edge() {
let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]);
assert_eq!(img.sample_bilinear(f32::INFINITY, f32::INFINITY), [2.0, 1.0, 1.0]);
assert_eq!(img.sample_bilinear(f32::MAX, 0.0), [2.0, 0.0, 1.0]);
assert_eq!(img.sample_bilinear(f32::NEG_INFINITY, f32::NEG_INFINITY), [0.0, 0.0, 1.0]);
assert_eq!(img.sample_bilinear(1.5, 0.5), [1.0, 0.0, 1.0], "an ordinary sample is as before");
}
#[test]
fn crop_and_rotate() {
let img = Image::<u32>::from_fn(4, 3, |x, y| (y * 10 + x) as u32);
+14 -1
View File
@@ -79,7 +79,20 @@ pub fn demosaic(n: &Normalized, method: Method) -> Rgb32f {
}
match (&n.cfa, n.cpp) {
(_, 3) => Rgb32f { width: w, height: h, data: n.data.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect() },
(_, cpp) if cpp != 1 => Rgb32f { width: w, height: h, data: n.data.chunks_exact(cpp).map(|c| [c[0], c[1], c[2]]).collect() },
// four colours develop from their first three; narrower data (refused by `RawImage::validate`, issue #1511)
// repeats its last sample instead of reading past the pixel
(_, cpp) if cpp != 1 => Rgb32f {
width: w,
height: h,
data: n
.data
.chunks_exact(cpp.max(1))
.map(|c| {
let s = |i: usize| c.get(i).or(c.last()).copied().unwrap_or(0.0);
[s(0), s(1), s(2)]
})
.collect(),
},
(None, _) => Rgb32f { width: w, height: h, data: n.data.iter().map(|&v| [v; 3]).collect() },
(Some(cfa), _) => {
let m = Mosaic { w, h, data: &n.data, cfa };
+11 -4
View File
@@ -207,7 +207,8 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
}
let info = raw.image()?;
let (w, h, cpp) = (info.width as usize, info.height as usize, info.samples_per_pixel as usize);
if !(1..=4).contains(&cpp) {
// one sample (CFA or monochrome), RGB or four colours; two are neither and can't be developed (issue #1511)
if !matches!(cpp, 1 | 3 | 4) {
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
}
let mut data = read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?;
@@ -251,12 +252,14 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
[r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize),
_ => (1, 1),
};
let mut values: Vec<f32> = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).collect();
// (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place)
let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0);
let mut values: Vec<f32> = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
if values.len() != br * bc * cpp {
values = vec![values.first().copied().unwrap_or(0.0)];
}
let (br, bc) = if values.len() == 1 { (1, 1) } else { (br, bc) };
let floats = |tag| raw.f64s(tag).unwrap_or_default().into_iter().map(|v| v as f32).collect::<Vec<f32>>();
let floats = |tag| raw.f64s(tag).unwrap_or_default().into_iter().map(level).collect::<Vec<f32>>();
let black =
BlackLevel { repeat_rows: br, repeat_cols: bc, values, delta_h: floats(t::BLACK_LEVEL_DELTA_H), delta_v: floats(t::BLACK_LEVEL_DELTA_V) };
@@ -303,7 +306,11 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
}
// the tag is anchored at the top-left of the ActiveArea (DNG spec); `RawImage::cfa` is anchored at the
// sensor origin (the crop to the active area shifts it back), so move the anchor out by the area's offset
Some(Cfa { width: cols, height: rows, pattern }.shifted(cols - active_area.x % cols, rows - active_area.y % rows))
// (LightCraft's own DNGs from before the writer followed the spec carry the sensor-origin pattern; they are marked by
// a bare "LightCraft" Software tag, the writer now says [`crate::dngwrite::SOFTWARE`], and keep their original reading)
let at_origin = Cfa { width: cols, height: rows, pattern };
let legacy_writer = [raw, ifd0].iter().any(|i| i.string(t::SOFTWARE).as_deref().map(str::trim) == Some("LightCraft"));
Some(if legacy_writer { at_origin } else { at_origin.shifted(cols - active_area.x % cols, rows - active_area.y % rows) })
} else {
None
};
+19 -6
View File
@@ -25,6 +25,10 @@ pub enum DngCompression {
},
}
/// Software tag of the DNGs this writer makes. A bare "LightCraft" marks older files, whose CFAPattern is anchored at
/// the sensor origin instead of the ActiveArea (the reader keeps their original reading).
pub const SOFTWARE: &str = "LightCraft DNG 2";
/// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept).
pub fn f32_to_f16(v: f32) -> u16 {
let b = v.to_bits();
@@ -108,18 +112,27 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
ifd.set(t::PHOTOMETRIC, Value::Short(vec![if raw.cfa.is_some() { photometric::CFA } else { photometric::LINEAR_RAW }]));
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
ifd.set(t::DNG_BACKWARD_VERSION, Value::Byte(vec![1, 3, 0, 0]));
let make = raw.metadata.make.clone().unwrap_or_default();
let model = raw.metadata.model.clone().unwrap_or_default();
let make = raw.metadata.make.as_deref().unwrap_or_default();
let model = raw.metadata.model.as_deref().unwrap_or_default();
if !make.is_empty() {
ifd.set(t::MAKE, Value::Ascii(make.clone()));
ifd.set(t::MAKE, Value::Ascii(make.to_owned()));
}
if !model.is_empty() {
ifd.set(t::MODEL, Value::Ascii(model.clone()));
ifd.set(t::MODEL, Value::Ascii(model.to_owned()));
}
let unique = format!("{make} {model}").trim().to_string();
let model_includes_make = !make.is_empty()
&& model.get(..make.len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case(make))
&& model.get(make.len()..).is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !c.is_alphanumeric() && c != '_'));
let unique = if model_includes_make || make.is_empty() {
model.to_owned()
} else if model.is_empty() {
make.to_owned()
} else {
format!("{make} {model}")
};
ifd.set(t::UNIQUE_CAMERA_MODEL, Value::Ascii(if unique.is_empty() { "LightCraft".into() } else { unique }));
ifd.set(t::ORIENTATION, Value::Short(vec![raw.orientation.to_exif()]));
ifd.set(t::SOFTWARE, Value::Ascii("LightCraft".into()));
ifd.set(t::SOFTWARE, Value::Ascii(SOFTWARE.into()));
let xmp = opts.xmp.clone().unwrap_or_else(|| lightcraft_meta::write_xmp(&raw.metadata, None));
ifd.set(t::XMP, Value::Byte(xmp.into_bytes()));
+9 -2
View File
@@ -56,7 +56,7 @@ pub use lightcraft_meta::Metadata;
pub use lightcraft_raster::Rgb32f;
pub use opcodes::{Opcode, OpcodeLists};
pub use preview::{
PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space,
PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space, embedded_preview_covering,
embedded_preview_dynamic_range_optimized, embedded_preview_from_range,
};
pub use semantic::{SemanticMask, semantic_masks};
@@ -715,6 +715,10 @@ impl RawImage {
if self.width == 0 || self.height == 0 || !(1..=4).contains(&self.cpp) {
return Err(RawError::Corrupt("bad dimensions".into()));
}
// two samples a pixel are neither one colour nor RGB: nothing downstream can develop them (issue #1511)
if self.cpp == 2 {
return Err(RawError::Unsupported("2 samples per pixel".into()));
}
if samples && self.data.len() != self.width * self.height * self.cpp {
return Err(RawError::Corrupt("data length mismatch".into()));
}
@@ -768,7 +772,10 @@ impl RawImage {
for s in 0..cpp {
let v = src(base + x * cpp + s);
let b = self.black.at(x, y, s, cpp);
row[x * cpp + s] = (v - b) * scale[s];
// a NaN or infinite sample (a float DNG's, or a black level a file got wrong)
// reads as black, so the demosaic and every stage after it see numbers
let n = (v - b) * scale[s];
row[x * cpp + s] = if n.is_finite() { n } else { 0.0 };
}
}
});
+126
View File
@@ -129,6 +129,76 @@ impl Opcode {
pub fn is_lens_correction(&self) -> bool {
matches!(self, Opcode::WarpRectilinear { .. } | Opcode::WarpRectilinear2 { .. } | Opcode::FixVignetteRadial { .. })
}
/// This lens correction re-expressed for the part `crop` of the `width × height` image it was written for: applied
/// to the cropped image it gives the crop of the corrected whole. The centre moves into the crop and the
/// coefficients are rescaled for the crop's farthest corner, the radius every coordinate is normalised by. Centres
/// use the warps' convention (0 and 1 at the first and last pixel), also for `FixVignetteRadial`, as the develop
/// pipeline reads them. `None` for other opcodes, an image or crop under 2 × 2, or a crop outside the image.
pub fn cropped(&self, width: usize, height: usize, crop: crate::Rect) -> Option<Opcode> {
let fits = crop.x.checked_add(crop.width).is_some_and(|r| r <= width) && crop.y.checked_add(crop.height).is_some_and(|b| b <= height);
if width < 2 || height < 2 || crop.width < 2 || crop.height < 2 || !fits {
return None;
}
let (aw, ah) = (width as f64 - 1.0, height as f64 - 1.0);
let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0);
// the centre in the crop, and the ratio of the crop's normalising radius to the image's
let reframe = |c: [f64; 2]| -> Option<([f64; 2], f64)> {
let (px, py) = (c[0] * aw, c[1] * ah);
let (qx, qy) = (px - crop.x as f64, py - crop.y as f64);
let s = max_radius(qx, qy, cw, ch) / max_radius(px, py, aw, ah);
(s.is_finite() && s > 0.0 && qx.is_finite() && qy.is_finite()).then_some(([qx / cw, qy / ch], s))
};
match self {
// source radius r·(kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶) and tangential terms quadratic in r: with r = r'·s,
// kr_i scales by s^2i and kt by s
Opcode::WarpRectilinear { planes, center } => {
let (center, s) = reframe(*center)?;
let planes = planes
.iter()
.map(|k| {
let mut k = *k;
for (i, v) in k[..4].iter_mut().enumerate() {
*v *= s.powi(2 * i as i32);
}
k[4] *= s;
k[5] *= s;
k
})
.collect();
Some(Opcode::WarpRectilinear { planes, center })
}
// radial factor Σ kr_i r^i between the valid radii: kr_i scales by s^i, the radii by 1/s
Opcode::WarpRectilinear2 { planes, center, reciprocal, optional } => {
let (center, s) = reframe(*center)?;
let planes = planes
.iter()
.map(|k| {
let mut k = *k;
for (i, v) in k[..15].iter_mut().enumerate() {
*v *= s.powi(i as i32);
}
k[15] *= s;
k[16] *= s;
k[17] /= s;
k[18] /= s;
k
})
.collect();
Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: *reciprocal, optional: *optional })
}
// gain 1 + Σ k_i r^(2i+2)
Opcode::FixVignetteRadial { k, center } => {
let (center, s) = reframe(*center)?;
let mut k = *k;
for (i, v) in k.iter_mut().enumerate() {
*v *= s.powi(2 * i as i32 + 2);
}
Some(Opcode::FixVignetteRadial { k, center })
}
_ => None,
}
}
}
/// The opcodes of a list that a reader applies, in order: all of them, minus the DNG 1.6 skip rule (specification
@@ -1032,4 +1102,60 @@ pub(crate) mod tests {
let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] };
assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. })));
}
/// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A
/// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ.
#[test]
fn cropped_lens_corrections_correct_the_crop_like_the_whole() {
let (w, h) = (90usize, 64usize);
let crop = crate::Rect::new(7, 5, 70, 50);
let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]);
let mut wr2 = [0.0; WR2_LEN];
wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]);
wr2[15] = 0.002;
wr2[16] = -0.001;
wr2[17] = 0.05;
wr2[18] = 0.9;
let warps = [
Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] },
Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true },
Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false },
];
for op in &warps {
let mut whole = ramp.clone();
apply_list3(std::slice::from_ref(op), &mut whole);
let re = op.cropped(w, h, crop).unwrap();
let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height);
apply_list3(std::slice::from_ref(&re), &mut part);
let mut worst = 0f32;
for y in 6..crop.height - 6 {
for x in 6..crop.width - 6 {
let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]);
worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max);
}
}
assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}");
}
// the vignette, in the warps' centre convention the develop pipeline reads it with
let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| {
let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0);
let (px, py) = (c[0] * aw, c[1] * ah);
let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2);
1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::<f64>()
};
let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]);
let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else {
panic!("vignette")
};
for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] {
let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y));
assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}");
}
// no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge
assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]);
assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none());
assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none());
assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none());
assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none());
}
}
+114 -19
View File
@@ -65,31 +65,43 @@ pub fn embedded_preview_dynamic_range_optimized(bytes: &[u8]) -> Option<bool> {
crate::vendor::arw::dynamic_range_optimizer(&mn)
}
/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
fn is_dct_jpeg(b: &[u8]) -> bool {
if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 {
return false;
/// The first frame (SOF) marker of a JPEG and its position, from the marker segments before the first scan.
fn first_frame(b: &[u8]) -> Option<(u8, usize)> {
if b.len() < 4 || !b.starts_with(&[0xff, 0xd8]) {
return None;
}
let mut i = 2;
while i + 4 <= b.len() {
if b[i] != 0xff {
return false;
let mut i = 2usize;
while i.checked_add(4)? <= b.len() {
if *b.get(i)? != 0xff {
return None;
}
let m = b[i + 1];
let m = *b.get(i + 1)?;
if m == 0xff {
i += 1;
continue;
}
match m {
0xc0..=0xc2 => return true,
0xc3 | 0xc5..=0xc7 | 0xcb | 0xcd..=0xcf => return false,
0xda | 0xd9 => return false,
0xc0..=0xc3 | 0xc5..=0xc7 | 0xcb | 0xcd..=0xcf => return Some((m, i)),
0xda | 0xd9 => return None,
_ => {}
}
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
i += 2 + len;
let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?]));
i = i.checked_add(2 + len)?;
}
false
None
}
/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
fn is_dct_jpeg(b: &[u8]) -> bool {
first_frame(b).is_some_and(|(m, _)| matches!(m, 0xc0..=0xc2))
}
/// Width, height and component count of a displayable (DCT) JPEG, from its frame header.
fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> {
let (_, i) = first_frame(b).filter(|(m, _)| matches!(m, 0xc0..=0xc2))?;
let at = |k: usize| b.get(i.checked_add(k)?).copied();
let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?])));
Some((be16(7)?, be16(5)?, at(9)?))
}
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
@@ -139,6 +151,33 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
/// `lightcraft_codecs::decode`.
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
preview_covering(bytes, None)
}
/// The smallest embedded JPEG whose long edge is at least `min_edge` and that looks like a copy of the largest preview
/// (its aspect ratio within 1 %, so not a letterboxed or cropped thumbnail, and as many colour components, so not a
/// grey mask or depth map), else [`embedded_preview`]. Only TIFF-based raws keep several: an A7R VI's 1616 × 1080
/// preview is 0.3 MB beside its 9984 × 6656 one (5.6 MB), so a stand-in up to 1616 px decodes about a twentieth of the data.
pub fn embedded_preview_covering(bytes: &[u8], min_edge: u32) -> Option<Vec<u8>> {
preview_covering(bytes, Some(min_edge))
}
/// Of the DCT JPEGs in `found`, the one with the shortest long edge of at least `min_edge` with `largest`'s aspect
/// ratio (within 1 %) and component count.
fn smallest_covering<'a>(found: &[&'a [u8]], largest: &[u8], min_edge: u32) -> Option<&'a [u8]> {
let (bw, bh, bc) = jpeg_frame(largest)?;
let like = |(w, h, c): (u32, u32, u8)| {
let (a, b) = (u64::from(w) * u64::from(bh), u64::from(h) * u64::from(bw));
w > 0 && h > 0 && c == bc && a.abs_diff(b) * 100 <= a
};
found
.iter()
.filter_map(|&s| jpeg_frame(s).filter(|&f| like(f) && f.0.max(f.1) >= min_edge).map(|(w, h, _)| (s, w.max(h))))
.min_by_key(|&(_, long)| long)
.map(|(s, _)| s)
}
fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> {
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec());
@@ -173,10 +212,11 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
if let Some(p) = crate::vendor::orf::preview(bytes) {
found.push(p);
}
let best = found
.into_iter()
.filter(|s| is_dct_jpeg(s) || is_jxl(s))
.max_by_key(|s| s.len())
let largest = found.iter().copied().filter(|s| is_dct_jpeg(s) || is_jxl(s)).max_by_key(|s| s.len());
let best = min_edge
.zip(largest)
.and_then(|(min, big)| smallest_covering(&found, big, min))
.or(largest)
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
@@ -543,6 +583,61 @@ mod tests {
}
}
/// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components.
fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec<u8> {
let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8];
j.extend_from_slice(&h.to_be_bytes());
j.extend_from_slice(&w.to_be_bytes());
j.push(nc);
for c in 0..nc {
j.extend_from_slice(&[c + 1, 0x11, 0]);
}
j.extend(std::iter::repeat_n(0x55u8, n));
j.extend_from_slice(&[0xff, 0xd9]);
j
}
/// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs.
fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec<u8> {
let ifd = |j: &[u8]| {
let mut ifd = IfdBuilder::new();
ifd.set(t::COMPRESSION, Value::Short(vec![6]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] });
ifd
};
let mut ifd0 = ifd(jpegs[0]);
for j in &jpegs[1..] {
ifd0.add_sub_ifd(ifd(j));
}
TiffWriter::default().write(&[ifd0]).unwrap()
}
/// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a
/// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough.
#[test]
fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() {
let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10));
// a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too
let mask = sized_jpeg(768, 512, 1, 20);
let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]);
assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest");
assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box");
assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small);
assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest");
assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big);
assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture");
// a small copy of the same shape (within 1 %) is taken
let thumb = sized_jpeg(160, 107, 3, 10);
let file = tiff_with_jpegs(&[&small, &big, &thumb]);
assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb);
assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one");
for n in (0..file.len()).step_by(7) {
let _ = embedded_preview_covering(&file[..n], 100);
}
}
/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box.
#[test]
fn cr3_prefers_the_full_size_jpeg_track() {
+18 -3
View File
@@ -13,9 +13,9 @@
//! tracks (keeping the raw track's coding headers and quantization maps, and the timed-metadata
//! track where Canon puts the as-shot white balance), a RAF's CFA strip (keeping its compression
//! header) and a Panasonic RW2's raw data. Formats whose header probe reads samples (NEF: masked
//! columns; ORF: bit depth and CFA phase; PEF: dark borders) and anything else get
//! [`HeaderPlan::Whole`], as does a structure that is incomplete, inconsistent or keeps metadata
//! inside its sample data: the plan never guesses.
//! columns; ORF: bit depth and CFA phase; PEF: dark borders; ARW6, for its header plan only: the
//! start of every tile) and anything else get [`HeaderPlan::Whole`], as does a structure that is
//! incomplete, inconsistent or keeps metadata inside its sample data: the plan never guesses.
use std::collections::HashSet;
@@ -236,6 +236,8 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
let chunks: Vec<(u64, u64)> = match ifd.image() {
// a preview's strips are read when the preview is wanted: only the raw image is skipped
Ok(info) if preview && !matches!(info.photometric, t::photometric::CFA | t::photometric::LINEAR_RAW) => continue,
// (Sony Compressed RAW 2: the header probe decodes the start of every tile; a preview reads none)
Ok(info) if !preview && info.compression == 32766 => return HeaderPlan::Whole("an ARW6 header probe reads its tiles"),
Ok(info) => info.chunks(len).iter().map(|c| (c.offset, c.len)).collect(),
// a raw IFD without the tags a TIFF image needs (an older CR2's) still lists its strips
Err(_) => match (ifd.u64s(t::STRIP_OFFSETS), ifd.u64s(t::STRIP_BYTE_COUNTS)) {
@@ -550,4 +552,17 @@ mod tests {
raf[104..108].copy_from_slice(&1000u32.to_be_bytes());
assert_eq!(run(&raf, 160, 0).0, HeaderPlan::Whole("RAF raw container"));
}
/// An ARW6 header probe reads every tile's headers and the first lines of its data, which lie in the strip a
/// plan would skip: here the second tile, past a first one of bulk size.
#[test]
fn arw6_is_described_from_the_whole_file() {
use crate::vendor::arw6_testenc::{Qis, arw6_file, one_tile_quantised};
let (tile, _) = one_tile_quantised(64, 48, 0, Qis::REAL);
let mut first = tile.clone();
first.resize(BULK as usize, 0);
let file = arw6_file(&[(0, 0, 64, 48, first), (64, 0, 64, 48, tile)], 128, 48);
assert!(crate::probe_info(&file).is_ok());
assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles"));
}
}
+76 -19
View File
@@ -47,6 +47,13 @@ pub fn check_image(data: &[u8], info: &ImageInfo) -> Result<usize> {
if chunks.is_empty() {
return Err(RawError::Corrupt("no image data chunks".into()));
}
// every visible sample needs a chunk: a short offsets table would leave zero-filled (black) regions
let (across, down) = info.grid();
let planes = if info.planar == 2 { cpp } else { 1 };
let expected = (across as usize).saturating_mul(down as usize).saturating_mul(planes);
if chunks.len() < expected {
return Err(RawError::Corrupt(format!("{} image data chunks, expected {expected}", chunks.len())));
}
// Plausibility bound before allocating: no supported coding stores more than ~2000 samples per byte
// (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations.
let available: u64 = chunks.iter().map(|c| chunk_bytes(data, c).map_or(0, |s| s.len() as u64)).sum();
@@ -102,21 +109,9 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] };
let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() };
let mut ok = 0usize;
let mut first_err = None;
for r in decoded {
let (c, px) = match r {
Ok(v) => v,
Err(e) => {
// a JPEG XL tile that cannot be decoded is not a partial image: the file stays preview-only
if info.compression == comp::JPEG_XL {
return Err(e);
}
first_err.get_or_insert(e);
continue;
}
};
ok += 1;
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
let (c, px) = r?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (x0, y0) = (c.x as usize, c.y as usize);
if x0 >= w || y0 >= h {
@@ -149,9 +144,6 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
}
}
}
if ok == 0 {
return Err(first_err.unwrap_or_else(|| RawError::Corrupt("no decodable chunks".into())));
}
Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) })
}
@@ -168,8 +160,8 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
comp::JPEG => {
let f = ljpeg::decode(src, n.saturating_mul(2).max(1 << 16))?;
if f.data.len() < n {
// Some writers encode edge tiles smaller than nominal: accept a frame that covers the rows it has.
if f.data.is_empty() || f.data.len() % (cw * cpp) != 0 {
// Some writers encode edge tiles smaller than nominal: accept a frame that covers the rows inside the image.
if f.data.is_empty() || f.data.len() % (cw * cpp) != 0 || f.data.len() / (cw * cpp) < vh {
return Err(RawError::Corrupt(format!("lossless JPEG tile has {} samples, expected {n}", f.data.len())));
}
}
@@ -377,4 +369,69 @@ mod lossy_tests {
assert!((px(20, 3)[0] as i32 - 220).abs() <= 3, "second tile: {:?}", px(20, 3));
assert!((px(10, 5)[1] as i32 - 150).abs() <= 6, "green ramp: {:?}", px(10, 5));
}
fn strip_info(compression: u16, offsets: Vec<u64>, byte_counts: Vec<u64>) -> ImageInfo {
ImageInfo {
width: 4,
height: 4,
bits_per_sample: vec![8],
samples_per_pixel: 1,
compression,
photometric: 1,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Strips { rows_per_strip: 2 },
offsets,
byte_counts,
}
}
/// A missing strip or a strip that fails to decode is an error, not a black band in a "successful" image.
#[test]
fn missing_or_failed_chunks_are_errors() {
let file = vec![9u8; 8 + 8];
// both strips present
let ok = strip_info(comp::NONE, vec![0, 8], vec![8, 8]);
assert!(read_image(&file, &ok, ByteOrder::Little, Packing::Msb).is_ok());
// the second strip has no offset
let missing = strip_info(comp::NONE, vec![0], vec![8]);
assert!(read_image(&file, &missing, ByteOrder::Little, Packing::Msb).is_err());
// the header-only read rejects it too, as the full decode will
assert!(read_image_in(crate::Mode::Header, &file, &missing, ByteOrder::Little, Packing::Msb).is_err());
// the second strip starts past the end of the file
let past_end = strip_info(comp::NONE, vec![0, 100], vec![8, 8]);
assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err());
}
/// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter.
#[test]
fn short_lossless_jpeg_tiles_are_errors() {
let tile = |rows: usize| ljpeg::encode(&vec![300u16; 8 * rows], 8, rows, 1, 12, 1, 0);
let decode = |height: u32, rows: usize| {
let t = tile(rows);
let info = ImageInfo {
width: 8,
height,
bits_per_sample: vec![12],
samples_per_pixel: 1,
compression: comp::JPEG,
photometric: 32803,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: 8, tile_height: 8 },
offsets: vec![0],
byte_counts: vec![t.len() as u64],
};
read_image(&t, &info, ByteOrder::Little, Packing::Msb)
};
// an edge tile coded at its in-image height, and a full tile
assert_eq!(decode(6, 6), Ok(RawData::U16(vec![300; 8 * 6])));
assert_eq!(decode(8, 8), Ok(RawData::U16(vec![300; 8 * 8])));
// half of the visible rows are missing
assert!(decode(8, 4).is_err());
}
}
+4
View File
@@ -1115,6 +1115,10 @@ mod tests {
("cr3-canon-m50-craw.cr3", 0x9aacbfe66f505e1bu64),
("cr3-canon-r8-raw.cr3", 0x63c1c6d8d1eb2312u64),
("cr3-canon-r8-craw.cr3", 0xf836a663a795a375u64),
("cr3-canon-rp-raw.cr3", 0x5cb0f46404b8b663u64),
("cr3-canon-rp-craw.cr3", 0xf48df06ca1d67382u64),
("cr3-canon-rp-raw-crop.cr3", 0x46808a9de3b3dcc7u64),
("cr3-canon-rp-craw-crop.cr3", 0x39a60c4efed4e1f4u64),
] {
let path = std::path::Path::new(&directory).join(name);
let bytes = std::fs::read(path).unwrap();
+1
View File
@@ -12,6 +12,7 @@ pub mod crx;
pub(crate) mod crx_wavelet;
pub mod kodak;
pub mod nef;
mod nef_lens;
pub mod nefc;
pub mod orf;
pub mod pef;
+50 -2
View File
@@ -177,7 +177,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer_static("RGGB"),
};
let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits);
let model = ifd0.string(t::MODEL).unwrap_or_default();
let body = measured_body(&model, w, h, bits);
let active_w = trailing_masked_columns(samples, w, bits);
// the white level is measured from the whole image (or known for a measured body); the probe (no samples in its
// result) takes the nominal one
@@ -212,7 +213,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
color: ColorData::default(),
wb_multipliers: wb,
linearized: false,
opcodes: OpcodeLists::default(),
// Nikon's in-file distortion correction, about the optical centre (which follows the sensor shift)
opcodes: OpcodeLists {
list3: super::nef_lens::distortion(&model, raw, Rect::new(0, 0, active_w, h), crop).into_iter().collect(),
..Default::default()
},
metadata,
};
img.validate_for(mode)?;
@@ -316,6 +321,49 @@ mod tests {
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
}
/// A Z 6II NEF carrying NikonNEFInfo gets Nikon's distortion correction as one warp, in the header probe and
/// the full decode alike, and keeps it through DNG export; another body with the same data gets none.
#[test]
fn nikon_in_file_distortion_becomes_a_warp() {
use super::super::nef_lens::tests::{distortion_block, nef_info_blob, offset_block};
let (w, h) = (48u32, 32u32);
let file = |model: &str| {
let words: Vec<u8> = (0..w * h).flat_map(|i| (600 + (i % 97) as u16 * 50).to_be_bytes()).collect();
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![14]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
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]));
let info = nef_info_blob(&[
(0x0005, distortion_block(3, 21.6, 4, [0.039895, 0.019211, -0.127218], -0.025043)),
(0x0008, offset_block(1.5, -0.75)),
]);
raw.set(0xc7d5, Value::Undefined(info));
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![words] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into()));
ifd0.set(t::MODEL, Value::Ascii(model.into()));
ifd0.add_sub_ifd(raw);
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
};
let bytes = file("NIKON Z 6_2");
let header = decode(&bytes, Mode::Header).unwrap();
let full = decode(&bytes, Mode::Full).unwrap();
assert_eq!(header.info(), full.info());
assert_eq!(header.opcodes.list3, full.opcodes.list3);
let [crate::Opcode::WarpRectilinear { planes, center }] = full.opcodes.list3.as_slice() else { panic!("{:?}", full.opcodes.list3) };
assert!((planes[0][0] - 1.0 / (1.0 - 0.025043)).abs() < 1e-5, "{planes:?}");
assert!((center[0] * 47.0 - (23.5 + 1.5)).abs() < 0.01 && (center[1] * 31.0 - (15.5 - 0.75)).abs() < 0.01, "{center:?}");
let dng = crate::write_dng(&full, &Default::default()).unwrap();
assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3);
full.develop(crate::Method::Bilinear).unwrap();
assert!(decode(&file("NIKON D850"), Mode::Full).unwrap().opcodes.list3.is_empty());
}
#[test]
fn preview_color_space_is_read_from_the_nikon_note() {
for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] {
+259
View File
@@ -0,0 +1,259 @@
//! Nikon's in-file distortion correction (raw SubIFD tag `0xc7d5`, NikonNEFInfo), expressed as a DNG warp for
//! the shared optics pipeline.
//!
//! Tag identity: the ExifTool Nikon tag-name documentation (NikonNEFInfo `0xc7d5`; its DistortionInfo `0x0005`
//! with DistortionCorrection and RadialDistortionCoefficient1..3). The model below (which stored value multiplies
//! which power, the normalisation, the fill-frame zoom and the optical-centre offset in entry `0x0008`) was inferred
//! independently by matching the raw against the camera's own corrected JPEG; see docs/nikon-lens-corrections.md.
//! No lens database or external decoder code.
use crate::{Opcode, Rect};
use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, parse_ifd_at};
const NEF_INFO: u16 = 0xc7d5;
const DISTORTION_INFO: u16 = 0x0005;
const CENTRE_OFFSET: u16 = 0x0008;
/// Bodies whose files were checked against the camera's JPEG (docs/nikon-lens-corrections.md). Matching tags on
/// another body do not establish its pixel pitch or image area; add a body only after checking its files.
const VALIDATED: &[&str] = &["NIKON Z 6_2", "NIKON Z 8", "NIKON Z f"];
/// The normalisation radius every checked file stores: the FX half-diagonal in millimetres.
const FX_HALF_DIAGONAL_MM: f64 = 21.6;
const SAMPLES: usize = 256;
/// `r_src = r_out * (1 + k3 rho^2 + k2 rho^4 + k1 rho^6) / (1 + zoom)` around the optical centre, `rho` the radius
/// over the half-diagonal of the image area (`k1..k3` as ExifTool names them, in the order they are stored).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Distortion {
k: [f64; 3],
zoom: f64,
}
/// A signed rational at `at` (`i32 / i32`), when it is one.
fn rational(b: &[u8], at: usize, order: ByteOrder) -> Option<f64> {
let n = order.u32(b.get(at..at + 4)?.try_into().ok()?) as i32;
let d = order.u32(b.get(at + 4..at + 8)?.try_into().ok()?) as i32;
(d > 0).then(|| f64::from(n) / f64::from(d)).filter(|v| v.is_finite())
}
/// The NikonNEFInfo entries: the blob is `"Nikon\0"`, a 4-byte version, then a little TIFF stream.
fn nef_info(raw: &Ifd) -> Option<(Ifd, ByteOrder)> {
let tiff = raw.bytes(NEF_INFO)?.strip_prefix(b"Nikon\0")?.get(4..)?;
let (order, false) = lightcraft_tiff::Tiff::sniff(tiff)? else { return None };
let first = order.u32(tiff.get(4..8)?.try_into().ok()?);
let opts = ParseOptions { follow_children: false, ..Default::default() };
let (ifd, _) = parse_ifd_at(tiff, u64::from(first), order, 0, false, &opts).ok()?;
Some((ifd, order))
}
/// DistortionInfo: `"0100"`, the correction mode (0 none), the normalisation radius (mm) at 8, the term count (4)
/// at 16, the coefficients at 20 / 28 / 36 and the zoom at 64, each a signed rational.
fn distortion_info(b: &[u8], order: ByteOrder) -> Option<Distortion> {
if b.get(0..4)? != b"0100" || *b.get(4)? == 0 {
return None;
}
let norm = rational(b, 8, order)?;
let terms = order.u32(b.get(16..20)?.try_into().ok()?);
if (norm - FX_HALF_DIAGONAL_MM).abs() > 0.05 || terms != 4 {
return None;
}
let k = [rational(b, 20, order)?, rational(b, 28, order)?, rational(b, 36, order)?];
let zoom = rational(b, 64, order)?;
// real lenses stay far inside these (the checked files: |k| < 0.13, |zoom| < 0.09)
(k.iter().all(|v| v.abs() < 1.0) && zoom.abs() < 0.5).then_some(Distortion { k, zoom })
}
/// Entry `0x0008`: `"0100"`, then the optical centre's offset from the image-area centre in pixels (x right,
/// y down), two signed rationals. Zero when the lens stabilises (the sensor stays centred).
fn centre_offset(b: &[u8], order: ByteOrder) -> Option<(f64, f64)> {
(b.get(0..4)? == b"0100").then_some(())?;
Some((rational(b, 4, order)?, rational(b, 12, order)?))
}
/// The correction of a `model` NEF whose raw IFD is `raw`, for the image area `crop` inside `active` (both in
/// sensor pixels), as an `OpcodeList3` warp in active-area coordinates. `None` for unvalidated bodies, other
/// image areas, missing or implausible data, and maps that fold.
pub(super) fn distortion(model: &str, raw: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> {
if !VALIDATED.contains(&model.trim()) {
return None;
}
let crop = crop.clipped(active.width, active.height);
if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 {
return None;
}
// the stored radius is the FX half-diagonal: only the full 3:2 image area (not a DX or other crop of the
// sensor) is normalised by it
if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 || (crop.width as f64) < active.width as f64 * 0.95 {
return None;
}
let (info, order) = nef_info(raw)?;
let d = distortion_info(info.bytes(DISTORTION_INFO)?, order)?;
let (ox, oy) = info.bytes(CENTRE_OFFSET).and_then(|b| centre_offset(b, order)).unwrap_or((0.0, 0.0));
let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0);
if ox.abs() > cw * 0.05 || oy.abs() > ch * 0.05 {
return None;
}
let half_diagonal = cw.hypot(ch) / 2.0;
let poly = |rho: f64| {
let x = rho * rho;
1.0 + x * (d.k[2] + x * (d.k[1] + x * d.k[0]))
};
// DNG normalises by the farthest active-area corner from the centre; the image area reaches at most there
let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0);
let (cx, cy) = (crop.x as f64 + cw / 2.0 + ox, crop.y as f64 + ch / 2.0 + oy);
let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy));
let a = farthest / half_diagonal;
// the source radius must grow with the output radius out to the farthest corner, or the map folds
let mut previous = -1.0;
for j in 0..=SAMPLES {
let rho = a * j as f64 / SAMPLES as f64;
let source = rho * poly(rho) / (1.0 + d.zoom);
if !source.is_finite() || source <= previous {
return None;
}
previous = source;
}
let s = 1.0 / (1.0 + d.zoom);
let a2 = a * a;
let planes = vec![[s, d.k[2] * a2 * s, d.k[1] * a2 * a2 * s, d.k[0] * a2 * a2 * a2 * s, 0.0, 0.0]];
Some(Opcode::WarpRectilinear { planes, center: [cx / ax, cy / ay] })
}
#[cfg(test)]
pub(in crate::vendor) mod tests {
use super::*;
use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value};
const AREA: Rect = Rect { x: 0, y: 0, width: 6064, height: 4040 };
const CROP: Rect = Rect { x: 8, y: 8, width: 6048, height: 4024 };
fn rat(v: f64, den: i32) -> [u8; 8] {
let mut b = [0u8; 8];
b[..4].copy_from_slice(&((v * f64::from(den)).round() as i32).to_le_bytes());
b[4..].copy_from_slice(&den.to_le_bytes());
b
}
/// DistortionInfo as the checked files lay it out (84 bytes, little-endian).
pub(in crate::vendor) fn distortion_block(mode: u8, norm: f64, terms: u32, k: [f64; 3], zoom: f64) -> Vec<u8> {
let mut b = vec![0u8; 84];
b[..4].copy_from_slice(b"0100");
b[4] = mode;
b[5] = 1;
b[8..16].copy_from_slice(&rat(norm, 4096));
b[16..20].copy_from_slice(&terms.to_le_bytes());
for (i, v) in k.iter().enumerate() {
b[20 + 8 * i..28 + 8 * i].copy_from_slice(&rat(*v, 1 << 20));
}
b[44..52].copy_from_slice(&rat(0.0, 1));
b[52..60].copy_from_slice(&rat(0.0, 1));
b[64..72].copy_from_slice(&rat(zoom, 1 << 20));
b
}
pub(in crate::vendor) fn offset_block(x: f64, y: f64) -> Vec<u8> {
let mut b = b"0100".to_vec();
b.extend_from_slice(&rat(x, 256));
b.extend_from_slice(&rat(y, 256));
b.extend_from_slice(&[0, 0, 0, 0]);
b
}
/// A NikonNEFInfo (`0xc7d5`) blob with the given entries.
pub(in crate::vendor) fn nef_info_blob(entries: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut info = IfdBuilder::new();
for (tag, data) in entries {
info.set(*tag, Value::Undefined(data.clone()));
}
let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[info]).unwrap();
let mut blob = b"Nikon\0\x01\x03\0\0".to_vec();
blob.extend_from_slice(&tiff);
blob
}
/// A raw IFD carrying a NikonNEFInfo blob with the given entries.
pub(super) fn raw_with(entries: &[(u16, Vec<u8>)]) -> Ifd {
Ifd { entries: vec![Entry { tag: NEF_INFO, value: Value::Undefined(nef_info_blob(entries)), offset: 0 }], ..Default::default() }
}
// a Z 24-70 f/4 S at 24 mm and at 70 mm, as stored in two of the checked files
const WIDE: ([f64; 3], f64) = ([0.039895, 0.019211, -0.127218], -0.025043);
const TELE: ([f64; 3], f64) = ([0.01681, -0.010601, 0.056758], 0.088301);
fn warp(model: &str, raw: &Ifd) -> Option<([f64; 6], [f64; 2])> {
match distortion(model, raw, AREA, CROP)? {
Opcode::WarpRectilinear { planes, center } => Some((planes[0], center)),
_ => None,
}
}
/// The source radius the warp gives for an output radius `rho` (in image-area half-diagonals) about the centre.
fn source_rho(p: [f64; 6], centre: [f64; 2], rho: f64) -> f64 {
let (ax, ay) = (AREA.width as f64 - 1.0, AREA.height as f64 - 1.0);
let (cx, cy) = (centre[0] * ax, centre[1] * ay);
let m = cx.max(ax - cx).hypot(cy.max(ay - cy));
let hd = (CROP.width as f64 - 1.0).hypot(CROP.height as f64 - 1.0) / 2.0;
let t2 = (rho * hd / m).powi(2);
rho * (p[0] + t2 * (p[1] + t2 * (p[2] + t2 * p[3])))
}
#[test]
fn the_warp_is_the_stored_polynomial_over_the_zoom() {
for (k, zoom) in [WIDE, TELE] {
let raw = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, k, zoom))]);
let (p, c) = warp("NIKON Z 6_2", &raw).unwrap();
for rho in [0.0, 0.25, 0.5, 0.75, 1.0] {
let x = rho * rho;
let want = rho * (1.0 + k[2] * x + k[1] * x * x + k[0] * x * x * x) / (1.0 + zoom);
assert!((source_rho(p, c, rho) - want).abs() < 2e-6, "{k:?} at {rho}");
}
// centred on the image area, which sits 8 px in from the sensor's top-left
assert!((c[0] - (8.0 + 6047.0 / 2.0) / 6063.0).abs() < 1e-12 && (c[1] - (8.0 + 4023.0 / 2.0) / 4039.0).abs() < 1e-12);
}
}
#[test]
fn the_optical_centre_follows_the_sensor_shift() {
let raw = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, TELE.0, TELE.1)), (CENTRE_OFFSET, offset_block(-90.1, 18.59))]);
let (_, c) = warp("NIKON Z 6_2", &raw).unwrap();
assert!((c[0] * 6063.0 - (8.0 + 6047.0 / 2.0 - 90.1)).abs() < 0.01, "{c:?}");
assert!((c[1] * 4039.0 - (8.0 + 4023.0 / 2.0 + 18.59)).abs() < 0.01, "{c:?}");
}
#[test]
fn refuses_unvalidated_bodies_and_unknown_layouts() {
let good = distortion_block(3, 21.6, 4, WIDE.0, WIDE.1);
assert!(warp("NIKON Z 6_2", &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some());
assert!(warp("NIKON Z f", &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some());
assert!(warp("NIKON Z 8", &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some());
for model in ["", "NIKON Z 6", "NIKON Z 6_3", "NIKON Z 9", "NIKON D850", "NIKON Z 6_2 x"] {
assert!(warp(model, &raw_with(&[(DISTORTION_INFO, good.clone())])).is_none(), "{model}");
}
// correction off, a DX radius, another term count, a wild coefficient or zoom, no NEFInfo at all
for bad in [
distortion_block(0, 21.6, 4, WIDE.0, WIDE.1),
distortion_block(3, 14.2, 4, WIDE.0, WIDE.1),
distortion_block(3, 21.6, 3, WIDE.0, WIDE.1),
distortion_block(3, 21.6, 4, [0.0, 0.0, 1.5], 0.0),
distortion_block(3, 21.6, 4, WIDE.0, 0.9),
b"0200".iter().copied().chain([0u8; 80]).collect(),
vec![0u8; 12],
] {
assert!(warp("NIKON Z 6_2", &raw_with(&[(DISTORTION_INFO, bad)])).is_none());
}
assert!(warp("NIKON Z 6_2", &Ifd::default()).is_none());
// a map that folds (the source radius turns back) is refused
assert!(warp("NIKON Z 6_2", &raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, [-0.9, 0.0, 0.0], 0.0))])).is_none());
}
#[test]
fn only_the_full_3_2_image_area_is_corrected() {
let raw = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, WIDE.0, WIDE.1))]);
// a DX or 1:1 crop of the sensor would need another normalisation
assert!(distortion("NIKON Z 6_2", &raw, AREA, Rect::new(1016, 676, 4032, 2688)).is_none(), "DX");
assert!(distortion("NIKON Z 6_2", &raw, AREA, Rect::new(1016, 8, 4024, 4024)).is_none());
assert!(distortion("NIKON Z 6_2", &raw, AREA, Rect::new(0, 0, 1, 1)).is_none());
// a sensor-shift offset beyond 5 % of the frame is not trusted
let far = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, WIDE.0, WIDE.1)), (CENTRE_OFFSET, offset_block(900.0, 0.0))]);
assert!(warp("NIKON Z 6_2", &far).is_none());
}
}
+35 -3
View File
@@ -279,14 +279,17 @@ pub(crate) fn decode_rows(src: &[u8], w: usize, h: usize, rows: usize, bits: u32
} else {
diff(&mut stream, &huff).ok_or_else(|| RawError::Corrupt(format!("NEF: invalid Huffman code at row {y}")))?
};
// a run of large differences in a corrupt stream can drive a predictor past i32 (issue #1515)
let overflow = || RawError::Corrupt(format!("NEF: predictor out of range at row {y}"));
let p = if x < 2 {
let s = seeds.get_mut(x).ok_or(RawError::Limit("NEF column"))?;
*s += d;
*s = s.checked_add(d).ok_or_else(overflow)?;
hpred[x & 1] = *s;
*s
} else {
hpred[x & 1] += d;
hpred[x & 1]
let p = hpred[x & 1].checked_add(d).ok_or_else(overflow)?;
hpred[x & 1] = p;
p
};
let v = p.clamp(0, max);
clipped += (v != p && (table.split == 0 || y < table.split)) as usize;
@@ -728,6 +731,35 @@ mod tests {
assert!(decode_rows(&src[..w * h / 4 - 1], w, h, 1, 14, &t).is_err(), "a strip too short for the image is corrupt in any mode");
}
/// A long run of the largest difference drove a predictor past i32: a panic in builds with overflow checks,
/// wrapped values otherwise (issue #1515). Both the row and the column predictors now stop with an error.
#[test]
fn predictor_overflow_is_an_error_not_a_panic() {
let codes = LOSSLESS_14.codes();
let k = (0..codes.len()).rev().find(|&k| codes[k].1 > 0).unwrap();
let d = (1u32 << k) - 1;
let stream = |n: usize| {
let mut wr = Writer::default();
let (code, len) = codes[k];
for _ in 0..n {
wr.put(code as u32 >> (12 - len), len as u32);
wr.put(d, k as u32);
}
wr.finish()
};
// differences one predictor needs to pass i32::MAX
let steps = (i32::MAX as u32 / d) as usize + 2;
let table = DecodeTable { encoding: Encoding::Lossless, seeds: [[0; 2]; 2], split: 0 };
// along a row: every other sample adds to the same predictor
let w = 2 * steps + 2;
let err = decode(&stream(w), w, 1, 14, &table).unwrap_err();
assert!(matches!(err, RawError::Corrupt(ref m) if m.contains("predictor")), "{err}");
// down two columns: each row adds to the seed of its parity
let h = 2 * steps + 2;
let err = decode(&stream(2 * h), 2, h, 14, &table).unwrap_err();
assert!(matches!(err, RawError::Corrupt(ref m) if m.contains("predictor")), "{err}");
}
#[test]
fn truncated_and_corrupted_streams_error_never_panic() {
let (w, h) = (64, 16);
+55 -6
View File
@@ -103,8 +103,10 @@ impl<'a> Bits<'a> {
pub fn consumed_bits(&self) -> usize {
self.pos * 8 - self.n as usize
}
/// Whether more bits were handed out than the source holds (the zeros read past its end are lookahead padding,
/// not data; using them would fabricate samples).
pub fn overrun(&self) -> bool {
self.pos > self.src.len() + 8
self.consumed_bits() > self.src.len() * 8
}
}
@@ -150,13 +152,17 @@ pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits
let row = &mut out[y * w..(y + 1) * w];
for (x, o) in row.iter_mut().enumerate() {
let d = diff(&mut bits, h).ok_or_else(|| RawError::Corrupt(format!("PEF: invalid Huffman code at row {y}")))?;
// a run of large differences in a corrupt stream can drive a predictor past i32 (issue #1515)
let overflow = || RawError::Corrupt(format!("PEF: predictor out of range at row {y}"));
let p = if x < 2 {
vpred[y & 1][x] += d;
hpred[x] = vpred[y & 1][x];
hpred[x]
let v = vpred[y & 1][x].checked_add(d).ok_or_else(overflow)?;
vpred[y & 1][x] = v;
hpred[x] = v;
v
} else {
hpred[x & 1] += d;
hpred[x & 1]
let v = hpred[x & 1].checked_add(d).ok_or_else(overflow)?;
hpred[x & 1] = v;
v
};
*o = p.clamp(0, max) as u16;
}
@@ -390,6 +396,49 @@ mod tests {
assert!(table(&tb[..20], ByteOrder::Big).is_err());
}
/// Zero-padded lookahead past the end must not be decoded as samples: with no entropy at all, the zero bits
/// are a valid code (category 1) in this table, so a reader that only limits the refill position accepts them.
#[test]
fn symbols_decoded_from_padding_past_the_end_are_rejected() {
let (tb, codes) = test_table();
let h = table(&tb, ByteOrder::Big).unwrap();
assert!(decode_huffman(&[], &h, 2, 1, 14).is_err());
// the same image with its real entropy still decodes, including the last byte's padding bits
let px = [5u16, 9];
let src = encode(&px, 2, &codes);
assert_eq!(decode_huffman(&src, &h, 2, 1, 14).unwrap(), px);
// one byte short of the real entropy is rejected
assert!(decode_huffman(&src[..src.len() - 1], &h, 2, 1, 14).is_err());
}
/// A long run of the largest difference drove a predictor past i32: a panic in builds with overflow checks,
/// wrapped values otherwise (issue #1515). Both the row and the column predictors now stop with an error.
#[test]
fn predictor_overflow_is_an_error_not_a_panic() {
let (tb, codes) = test_table();
let h = table(&tb, ByteOrder::Big).unwrap();
let (c, l) = codes[14];
let d = (1u32 << 14) - 1;
let stream = |n: usize| {
let mut bits: Vec<bool> = Vec::with_capacity(n * (l as usize + 14));
for _ in 0..n {
(0..l).rev().for_each(|i| bits.push((c >> (12 - l)) >> i & 1 == 1));
(0..14).rev().for_each(|i| bits.push(d >> i & 1 == 1));
}
bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (b as u8) << (7 - i))).collect::<Vec<u8>>()
};
// differences one predictor needs to pass i32::MAX
let steps = (i32::MAX as u32 / d) as usize + 2;
// along a row: every other sample adds to the same predictor
let w = 2 * steps + 2;
let err = decode_huffman(&stream(w), &h, w, 1, 14).unwrap_err();
assert!(matches!(err, RawError::Corrupt(ref m) if m.contains("predictor")), "{err}");
// down two columns: each row adds to the predictor of its parity
let rows = 2 * steps + 2;
let err = decode_huffman(&stream(2 * rows), &h, 2, rows, 14).unwrap_err();
assert!(matches!(err, RawError::Corrupt(ref m) if m.contains("predictor")), "{err}");
}
/// A big-endian one-strip PEF-style TIFF of `w × h` 12-bit samples with the given compression tag and strip
/// byte count, around `payload`.
fn tiny_pef(w: u16, h: u16, compression: u16, strip_len: u32, payload: &[u8]) -> Vec<u8> {
+35
View File
@@ -1018,6 +1018,41 @@ fn sony_embedded_distortion_is_limited_to_validated_models() {
eprintln!("Sony distortion model boundary: {checked} corpus files checked");
}
/// Nikon's in-file distortion correction (docs/nikon-lens-corrections.md) reaches only the validated bodies: the
/// Z f sample (a Tamron 35-150 at 150 mm) gets one warp, the DSLR and Coolpix samples none, and the header probe
/// describes every file as its full decode does.
#[test]
fn nikon_in_file_distortion_is_limited_to_validated_bodies() {
let root = corpus_root().join("raw");
let mut checked = 0;
for (name, model, expected) in [
("nef-nikon-zf-lossless.nef", "NIKON Z f", 1),
("nef-nikon-d5100-lossless.nef", "NIKON D5100", 0),
("nef-nikon-d5100-uncompressed.nef", "NIKON D5100", 0),
("nef-nikon-d7000-lossy12.nef", "NIKON D7000", 0),
("nef-nikon-d7500-lossless12.nef", "NIKON D7500", 0),
("nef-nikon-d7500-lossless14.nef", "NIKON D7500", 0),
("nrw-nikon-b700-uncompressed.nrw", "COOLPIX B700", 0),
] {
let path = root.join(name);
if !path.exists() {
eprintln!("skip: {} absent", path.display());
continue;
}
let bytes = std::fs::read(&path).unwrap();
let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}");
assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}");
assert_eq!(full.opcodes.list3.len(), expected, "{name}");
if let Some(lightcraft_raw::Opcode::WarpRectilinear { planes, .. }) = full.opcodes.list3.first() {
// the stored zoom (0.0557) frames the corrected picture as the camera does
assert!((planes[0][0] - 1.0 / 1.055719).abs() < 1e-4, "{name}: {planes:?}");
}
checked += 1;
}
eprintln!("Nikon distortion boundary: {checked} corpus files checked");
}
/// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the
/// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the
/// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either.
+28
View File
@@ -124,3 +124,31 @@ fn canon_m50_craw_crop_is_the_shot_aspect() {
let raw = decode(&bytes).unwrap();
assert_eq!((raw.crop.x, raw.crop.y, raw.crop.width, raw.crop.height), (1012, 12, 4000, 4000));
}
#[test]
fn canon_rp_lossless_sensor_is_exact() {
// raw.pixls.us/getfile.php/4674; SHA256
// dcbd0f6f9929de2e2331b0fd10a9d01e7cfd04c5a5e4038456de1647d714cdf2
check("cr3-canon-rp-raw.cr3", 0x5cb0_f464_04b8_b663, (6384, 4224), (6240, 4160));
}
#[test]
fn canon_rp_craw_sensor_is_exact() {
// raw.pixls.us/getfile.php/4672; SHA256
// bd3270610593d37ff8a399908bb9406a4e32f9151d55bf19ac1cf8f10d787b46
check("cr3-canon-rp-craw.cr3", 0xf48d_f06c_a1d6_7382, (6384, 4224), (6240, 4160));
}
#[test]
fn canon_rp_crop_mode_lossless_sensor_is_exact() {
// 1.6x crop mode: a single tile. raw.pixls.us/getfile.php/4673; SHA256
// 7d4ba45dc9264d598336133593c677b09774bcdf13b602ee770e72415a652a16
check("cr3-canon-rp-raw-crop.cr3", 0x4680_8a9d_e3b3_dcc7, (4032, 2656), (3888, 2592));
}
#[test]
fn canon_rp_crop_mode_craw_sensor_is_exact() {
// raw.pixls.us/getfile.php/4671; SHA256
// 4a859ab137f792aedaf3416b0aad1497ebaab02052a49453902e226699154aa7
check("cr3-canon-rp-craw-crop.cr3", 0x39a6_0c4e_fed4_e1f4, (4032, 2656), (3888, 2592));
}
+82
View File
@@ -90,6 +90,45 @@ fn assert_same(a: &RawImage, b: &RawImage) {
assert_eq!(b.metadata.rating, Some(3));
}
#[test]
fn writer_camera_identity_tags_roundtrip() {
let cases = [
(Some("Canon"), Some("Canon EOS R6"), "Canon EOS R6"),
(Some("CANON"), Some("cAnOn EOS R6"), "cAnOn EOS R6"),
(Some("Canon"), Some("Canon"), "Canon"),
(Some("Canon"), Some("Canon-EOS R6"), "Canon-EOS R6"),
(Some("Canon"), Some("Canon_EOS R6"), "Canon Canon_EOS R6"),
(Some("Canon"), Some("Canonic R6"), "Canon Canonic R6"),
(Some("Canon"), Some("Canon2 R6"), "Canon Canon2 R6"),
(Some("Canon"), Some("Canoné R6"), "Canon Canoné R6"),
(Some("Canon"), Some("EOS R6"), "Canon EOS R6"),
(Some("Eastman Kodak"), Some("eAsTmAn kOdAk DCS Pro"), "eAsTmAn kOdAk DCS Pro"),
(Some("Eastman Kodak"), Some("Eastman Kodakish DCS Pro"), "Eastman Kodak Eastman Kodakish DCS Pro"),
(Some("Eastman Kodak"), Some("Eastman DCS Pro"), "Eastman Kodak Eastman DCS Pro"),
(Some(""), Some("EOS R6"), "EOS R6"),
(Some("Canon"), Some(""), "Canon"),
(Some(""), Some(""), "LightCraft"),
(None, Some("EOS R6"), "EOS R6"),
(Some("Canon"), None, "Canon"),
(None, None, "LightCraft"),
];
for (make, model, unique) in cases {
let mut raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
raw.metadata.make = make.map(str::to_owned);
raw.metadata.model = model.map(str::to_owned);
for order in [ByteOrder::Little, ByteOrder::Big] {
let bytes = write_dng(&raw, &DngWriteOptions { compression: DngCompression::Uncompressed, order, xmp: None }).unwrap();
let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap();
let ifd = &tiff.ifds[0];
assert_eq!(ifd.value(t::UNIQUE_CAMERA_MODEL), Some(&Value::Ascii(unique.into())), "{make:?} / {model:?}, {order:?}");
for (tag, original) in [(t::MAKE, make), (t::MODEL, model)] {
let expected = original.filter(|s| !s.is_empty()).map(|s| Value::Ascii(s.into()));
assert_eq!(ifd.value(tag), expected.as_ref(), "tag {tag}, {make:?} / {model:?}, {order:?}");
}
}
}
}
#[test]
fn writer_roundtrip_all_layouts() {
for (w, h) in [(64, 48), (70, 45), (33, 17)] {
@@ -1047,3 +1086,46 @@ fn cfa_pattern_is_relative_to_the_active_area() {
assert_eq!(back.normalized().unwrap().cfa.unwrap().name(), "GRBG");
}
}
/// Two samples a pixel are neither one colour nor RGB. A DNG with them decoded, and developing it read a third sample
/// past each pixel, a panic in release builds too (issue #1511). Such data is refused with a reason, and the demosaic
/// itself no longer reads past a pixel; one, three and four samples develop as before.
#[test]
fn two_samples_per_pixel_are_refused_not_a_panic() {
let (w, h) = (8, 6);
let file = |cpp: usize| {
let mut raw = base_ifd(w, h, 16, cpp, false);
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&pattern(w, h, cpp, 16), ByteOrder::Little)] });
dng(raw, ByteOrder::Little)
};
let err = decode(&file(2)).unwrap_err();
assert!(matches!(err, lightcraft_raw::RawError::Unsupported(ref m) if m.contains("2 samples")), "{err}");
for cpp in [1, 3, 4] {
decode(&file(cpp)).unwrap().develop(Method::Bilinear).unwrap();
}
// a raw image built with two samples is refused when it develops
let raw = synthetic(16, 12, None, 2);
let err = raw.develop(Method::Ahd).unwrap_err();
assert!(matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}");
// the demosaic of such data repeats the last sample instead of reading past the pixel
let n = lightcraft_raw::Normalized { width: 2, height: 1, cpp: 2, data: vec![0.1, 0.2, 0.3, 0.4], cfa: None };
assert_eq!(lightcraft_raw::demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.2], [0.3, 0.4, 0.4]]);
}
/// DNGs LightCraft wrote before the writer anchored the pattern at the ActiveArea carry a bare "LightCraft" Software
/// tag and a sensor-origin pattern; they keep that reading, while the current writer marks its files differently.
#[test]
fn legacy_lightcraft_dng_keeps_the_sensor_anchored_pattern() {
let (w, h) = (20, 12);
let build = |software: &str| {
let mut raw = base_ifd(w, h, 16, 1, true);
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&pattern(w, h, 1, 16), ByteOrder::Little)] });
raw.set(t::WHITE_LEVEL, Value::Short(vec![65535]));
raw.set(t::ACTIVE_AREA, Value::Long(vec![1, 0, h as u32 - 1, w as u32 - 1]));
raw.set(t::SOFTWARE, Value::Ascii(software.into()));
decode(&dng(raw, ByteOrder::Little)).unwrap()
};
assert_eq!(build("LightCraft").cfa.unwrap().name(), "GRBG");
assert_eq!(build("Adobe Photoshop Lightroom").cfa.unwrap().name(), "BGGR");
assert_ne!(lightcraft_raw::dngwrite::SOFTWARE, "LightCraft");
}
+65
View File
@@ -102,3 +102,68 @@ fn hostile_dimensions_are_rejected_quickly() {
assert!(lightcraft_raw::decode(&bytes).is_err());
assert!(t0.elapsed().as_secs_f64() < 2.0);
}
/// A Bayer DNG whose `BlackLevel` is a FLOAT holding NaN (or infinity), and a float CFA DNG with
/// NaN samples: every normalised sample came out NaN, and AHD's green estimate clamped between two
/// NaN neighbours, which panics (the 1:1 view and every export of such a file). Non-finite levels
/// read as 0 and non-finite samples as black: the photo develops to finite numbers.
#[test]
fn non_finite_black_levels_and_samples_develop() {
use lightcraft_tiff::tags as t;
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
let (w, h) = (32u32, 16u32);
for black in [Value::Float(vec![f32::NAN]), Value::Double(vec![f64::INFINITY]), Value::Float(vec![64.0, f32::NAN, 64.0, 64.0])] {
let mut ifd = IfdBuilder::new();
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
ifd.set(t::BLACK_LEVEL_REPEAT_DIM, Value::Short(vec![2, 2]));
ifd.set(t::BLACK_LEVEL, black.clone());
ifd.set(t::WHITE_LEVEL, Value::Long(vec![4095]));
let strip: Vec<u8> = (0..w * h).flat_map(|i| (100 + (i * 37 % 3000) as u16).to_le_bytes()).collect();
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] });
let bytes = TiffWriter::default().write(&[ifd]).unwrap();
let img = lightcraft_raw::decode(&bytes).unwrap_or_else(|e| panic!("{black:?}: {e}"));
let rgb = img.develop(Method::Ahd).unwrap();
assert!(rgb.data.iter().flatten().all(|v| v.is_finite()), "{black:?}: finite output");
let _ = img.develop(Method::Ppg);
}
// float samples: a block of NaN and infinities in the middle of the mosaic
let (w, h) = (24usize, 16usize);
let data: Vec<f32> = (0..w * h)
.map(|i| match (i % w, i / w) {
(8..=13, 5..=10) => f32::NAN,
(2, _) => f32::INFINITY,
_ => (i * 97 % 1000) as f32 / 1000.0,
})
.collect();
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::F32(data),
cfa: Some(Cfa::bayer("RGGB").unwrap()),
bits: 32,
black: BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::Normal,
color: ColorData::default(),
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Default::default(),
};
for img in [raw.clone(), lightcraft_raw::decode(&lightcraft_raw::write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap()] {
let rgb = img.develop(Method::Ahd).unwrap();
assert!(rgb.data.iter().flatten().all(|v| v.is_finite()), "float NaN samples: finite output");
}
}
+16 -1
View File
@@ -68,7 +68,7 @@ impl Scene {
paint::render(self.kind, self.seed, w, h)
}
/// Render fitting within `max_edge` on the long side.
/// Render fitting within `max_edge` on the long side (at most the scene's own size).
pub fn render_fit(&self, max_edge: usize) -> Rgb32f {
let (w, h) = self.fit(max_edge);
self.render(w, h)
@@ -81,6 +81,9 @@ impl Scene {
}
fn fit(&self, max_edge: usize) -> (usize, usize) {
// never past the scene's own size: that is a demo photo's full resolution, and a size
// from a damaged catalog (a width of 4294967295) asked for more memory than exists
let max_edge = max_edge.min(self.width.max(self.height).max(1) as usize);
let a = self.aspect();
let (w, h) = if a >= 1.0 { (max_edge, (max_edge as f32 / a).round() as usize) } else { ((max_edge as f32 * a).round() as usize, max_edge) };
(w.max(1), h.max(1))
@@ -205,6 +208,18 @@ mod tests {
}
}
/// Found by fuzzing catalog snapshots: a demo photo whose width read 4294967295 asked its scene
/// for a render that size, and the pixel buffer's size overflowed (a panic on the render
/// thread). A scene renders at most at its own size, which is the photo's full resolution.
#[test]
fn a_scene_renders_at_most_at_its_own_size() {
let mut s = demo_library().remove(0);
(s.width, s.height) = (40, 30);
let img = s.render_fit(usize::MAX);
assert_eq!((img.width, img.height), (40, 30));
assert_eq!(s.fit(20), (20, 15), "smaller sizes as asked");
}
#[test]
fn sunsets_have_highlight_headroom() {
let lib = demo_library();
+24 -4
View File
@@ -76,6 +76,11 @@ impl Weights {
self.entries.contains_key(name)
}
/// The shape stored in the header for `name`, without reading its data.
pub fn shape(&self, name: &str) -> Option<&[usize]> {
self.entries.get(name).map(|e| e.shape.as_slice())
}
/// Tensor `name` as stored, on the CPU.
pub fn read(&self, name: &str) -> Result<Tensor> {
let e = self.entries.get(name).ok_or_else(|| Error::Model(format!("missing tensor `{name}`")))?;
@@ -97,11 +102,13 @@ pub struct Backend(pub Arc<Weights>);
impl SimpleBackend for Backend {
fn get(&self, s: Shape, name: &str, _h: candle_nn::Init, dtype: DType, dev: &Device) -> candle_core::Result<Tensor> {
let t = self.get_unchecked(name, dtype, dev)?;
if t.shape() != &s {
candle_core::bail!("shape mismatch for {name}: expected {s:?}, got {:?}", t.shape())
// compare against the header first, so a wrong-shaped tensor is never read or copied to the device
if let Some(stored) = self.0.shape(name)
&& stored != s.dims()
{
candle_core::bail!("shape mismatch for {name}: expected {s:?}, got {stored:?}")
}
Ok(t)
self.get_unchecked(name, dtype, dev)
}
fn get_unchecked(&self, name: &str, dtype: DType, dev: &Device) -> candle_core::Result<Tensor> {
@@ -127,6 +134,19 @@ mod tests {
p
}
#[test]
fn wrong_expected_shape_fails_before_the_data_is_read() {
let p = write("shape", r#"{"a":{"dtype":"F32","shape":[2,2],"data_offsets":[0,16]}}"#, 16);
let backend = Backend(Weights::open(&p).unwrap());
let ok = backend.get(Shape::from((2, 2)), "a", candle_nn::Init::Const(0.0), DType::F32, &Device::Cpu).unwrap();
assert_eq!(ok.dims(), &[2, 2]);
// cut the data off after opening: a read would now fail with an I/O error instead of a shape error
std::fs::OpenOptions::new().write(true).open(&p).unwrap().set_len(8).unwrap();
let err = backend.get(Shape::from((3, 3)), "a", candle_nn::Init::Const(0.0), DType::F32, &Device::Cpu).unwrap_err().to_string();
assert!(err.contains("shape mismatch"), "{err}");
let _ = std::fs::remove_file(p);
}
#[test]
fn tensors_must_lie_inside_the_file() {
let ok = write("ok", r#"{"a":{"dtype":"F32","shape":[2,2],"data_offsets":[0,16]}}"#, 16);
+4 -3
View File
@@ -140,9 +140,10 @@ impl<'a> Ctx<'a> {
}
entries.sort_by_key(|e| e.tag);
entries.dedup_by_key(|e| e.tag);
let next_pos = table + (n as usize).saturating_mul(entry_size);
let next = if n as usize == n_read {
match self.read_off(next_pos) {
// the next-IFD offset follows the whole table, so only a table read in full has one: a count far past the
// file (a BigTIFF's is 64-bit) must not reach the arithmetic (issue #1516)
let next = if n == n_read as u64 {
match n_read.checked_mul(entry_size).and_then(|len| table.checked_add(len)).and_then(|p| self.read_off(p)) {
Some(0) | None => 0,
Some(v) => base.checked_add(v).unwrap_or(0),
}
+34
View File
@@ -393,3 +393,37 @@ proptest! {
prop_assert_eq!(t.ifds[0].f64s(501).unwrap().len(), v.len());
}
}
/// A BigTIFF whose IFD says it has an enormous number of entries, with one readable, computed where the next-IFD
/// offset would be from the advertised count: an add overflow, a panic in builds with overflow checks (issue
/// #1516). The readable entry is kept and there is no next IFD; complete chains still link.
#[test]
fn bigtiff_with_an_enormous_entry_count_does_not_panic() {
for order in ORDERS {
for count in [u64::MAX, 1 << 62, 2] {
// little-endian bytes, reversed for big-endian files
let e = |le: &[u8]| -> Vec<u8> { if order == ByteOrder::Little { le.to_vec() } else { le.iter().rev().copied().collect() } };
let mut f = if order == ByteOrder::Little { b"II".to_vec() } else { b"MM".to_vec() };
for part in [e(&43u16.to_le_bytes()), e(&8u16.to_le_bytes()), e(&0u16.to_le_bytes()), e(&16u64.to_le_bytes()), e(&count.to_le_bytes())] {
f.extend(part);
}
// one entry: ImageWidth, LONG, 1 value (inline in the 8-byte field)
for part in [e(&tags::IMAGE_WIDTH.to_le_bytes()), e(&4u16.to_le_bytes()), e(&1u64.to_le_bytes()), e(&640u32.to_le_bytes())] {
f.extend(part);
}
f.extend([0; 4]);
let (ifd, next) = parse_ifd_at(&f, 16, order, 0, true, &ParseOptions::default()).unwrap();
assert_eq!(ifd.value(tags::IMAGE_WIDTH), Some(&Value::Long(vec![640])), "{order:?} {count}");
assert_eq!(next, 0, "{order:?} {count}");
let t = Tiff::parse(&f).unwrap();
assert_eq!(t.ifds.len(), 1);
}
// a complete chain of two IFDs still links
let mut a = IfdBuilder::new();
a.set(1000, Value::Short(vec![1]));
let mut b = IfdBuilder::new();
b.set(1000, Value::Short(vec![2]));
let t = Tiff::parse(&TiffWriter::new(order, true).write(&[a, b]).unwrap()).unwrap();
assert_eq!(t.ifds.len(), 2, "{order:?}");
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Slider-drag latency through the whole app: the real session, renderer, job pool and GPU (when
//! there is one), driven by a headless host at 60 frames a second, as a window with vsync would be.
//!
//! `cargo run --release -p lightcraft-ui-egui --example drag_latency -- LIBRARY_DIR PHOTO [CONTROL…]`
//! Env: `SIZE=1920x1200` (points), `SCALE=1`, `TICKS=30`, `GPU=0` for the CPU pipeline.
//!
//! Per control: a drag of `TICKS` value changes, one per frame; for each tick the frames (and ms)
//! until its draft became the loupe texture, how many ticks were never shown, the UI thread's
//! frame time, and after release the time until the full-quality render is the texture. (A window
//! presents the texture one frame later: add ~16.7 ms for photons.)
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashMap;
use std::time::{Duration, Instant};
use lightcraft_engine::Session;
use lightcraft_ui_egui::headless::Headless;
use lightcraft_ui_egui::render::Slot;
use lightcraft_ui_egui::{LightcraftApp, Services};
use serde_json::json;
const FRAME: Duration = Duration::from_micros(16_667);
const CONTROLS: &[(&str, f64, f64)] = &[
("light.exposure", 0.0, 1.0),
("light.contrast", 0.0, 50.0),
("light.highlights", 0.0, -60.0),
("light.shadows", 0.0, 60.0),
("light.whites", 0.0, 40.0),
("light.blacks", 0.0, -40.0),
("wb.temp", 5000.0, 6500.0),
("wb.tint", 0.0, 20.0),
("color.vibrance", 0.0, 40.0),
("color.saturation", 0.0, 30.0),
("curve.shadows", 0.0, 30.0),
("mixer.orange.hue", 0.0, 30.0),
("mixer.blue.sat", 0.0, -40.0),
("grading.shadows.sat", 0.0, 30.0),
("calibration.redHue", 0.0, 30.0),
("effects.texture", 0.0, 40.0),
("effects.clarity", 0.0, 40.0),
("effects.dehaze", 0.0, 30.0),
("vignette.amount", 0.0, -40.0),
("grain.amount", 0.0, 30.0),
("detail.sharpenAmount", 40.0, 90.0),
("detail.nrLuminance", 0.0, 40.0),
("detail.nrColor", 25.0, 60.0),
("optics.distortion", 0.0, 30.0),
("optics.vignetting", 0.0, 50.0),
("optics.defringePurple", 0.0, 5.0),
("geometry.vertical", 0.0, 20.0),
];
/// Step one frame at the 60 Hz cadence that started at `t0`.
fn frame(h: &mut Headless, t0: Instant, n: &mut u32) -> f64 {
*n += 1;
let due = t0 + FRAME * *n;
if let Some(d) = due.checked_duration_since(Instant::now()) {
std::thread::sleep(d);
}
let t = Instant::now();
h.step();
t.elapsed().as_secs_f64() * 1e3
}
/// The view slot measured: the zoom window with `ZOOM=<percent>` (set after opening), else the loupe.
fn watch() -> Slot {
if ZOOMED.load(std::sync::atomic::Ordering::Relaxed) { Slot::Region } else { Slot::Main }
}
static ZOOMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn settle(h: &mut Headless, id: lightcraft_catalog::PhotoId) {
let t = Instant::now();
while t.elapsed() < Duration::from_secs(120) {
h.step();
let r = &h.app.renderer;
let done = r.textures.get(&watch()).is_some_and(|t| t.photo == id && Some(t.key) == r.wanted(watch())) && !r.is_pending(watch());
if done && !h.busy() {
return;
}
std::thread::sleep(Duration::from_millis(2));
}
eprintln!("settle: timed out");
}
fn pct(v: &mut [f64], p: f64) -> f64 {
if v.is_empty() {
return f64::NAN;
}
v.sort_by(|a, b| a.total_cmp(b));
v[((v.len() - 1) as f64 * p).round() as usize]
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let (lib, file) = (args.first().expect("LIBRARY_DIR"), args.get(1).expect("PHOTO"));
let only: Vec<&str> = args[2..].iter().map(String::as_str).collect();
let env = |k: &str, d: &str| std::env::var(k).unwrap_or_else(|_| d.to_string());
let (w, hgt) = env("SIZE", "1920x1200").split_once('x').map(|(a, b)| (a.parse::<f32>().unwrap(), b.parse::<f32>().unwrap())).unwrap();
let scale: f32 = env("SCALE", "1").parse().unwrap();
let ticks: u32 = env("TICKS", "30").parse().unwrap();
let gpu = env("GPU", "1") != "0";
let mut s = Session::new().with_fs().with_system_clock();
s.open_library(lib, false).unwrap();
let r = s.execute("library.import", &json!({"paths": [file]})).unwrap();
let id = r["imported"].as_array().and_then(|a| a.first()).and_then(|v| v.as_u64()).map(lightcraft_catalog::PhotoId).unwrap_or_else(|| {
// already in the library
let name = std::path::Path::new(file).file_name().unwrap().to_string_lossy().to_string();
s.catalog.photos().find(|p| p.file_name == name).map(|p| p.id).expect("photo in library")
});
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
let mut app = LightcraftApp::new(s, Services::default());
app.ui.settings.gpu = gpu;
let mut h = Headless::new(app, [w, hgt], scale);
let t = Duration::from_secs(60);
h.request("ui.set", json!({"view": "detail", "right": "edit", "filmstrip": true}), t);
let t_open = Instant::now();
settle(&mut h, id);
if let Ok(z) = std::env::var("ZOOM") {
let pct: f64 = z.parse().unwrap();
h.app.run("view.navigate", json!({"zoom": {"percent": pct}, "pan": [0.5, 0.5]})).unwrap();
for _ in 0..5 {
h.step();
}
ZOOMED.store(true, std::sync::atomic::Ordering::Relaxed);
let t = Instant::now();
settle(&mut h, id);
eprintln!("zoomed to {pct} % in {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
}
let tex = h.app.renderer.textures.get(&watch()).map(|t| t.size);
eprintln!("opened in {:.0} ms, loupe texture {tex:?}, gpu {gpu}", t_open.elapsed().as_secs_f64() * 1e3);
println!("control,shown/ticks,lat_frames_med,lat_ms_med,lat_ms_p90,lat_ms_max,frame_ms_med,frame_ms_max,final_ms,draft_px,final_px");
// the controls named (in that order, repeats allowed), else all of them
let list: Vec<(&str, f64, f64)> =
if only.is_empty() { CONTROLS.to_vec() } else { only.iter().filter_map(|n| CONTROLS.iter().find(|c| c.0 == *n).copied()).collect() };
for (control, a, b) in list {
let mut asked: HashMap<u64, (u32, Instant)> = HashMap::new();
let mut lat_frames = Vec::new();
let mut lat_ms = Vec::new();
let mut frame_ms = Vec::new();
let mut shown_key = h.app.renderer.textures.get(&watch()).map(|t| t.key);
let mut draft_px = None;
let t0 = Instant::now();
let mut n = 0u32;
h.app.run("develop.beginInteraction", json!({"label": control})).unwrap();
#[allow(clippy::too_many_arguments)]
fn observe(
h: &Headless,
n: u32,
asked: &HashMap<u64, (u32, Instant)>,
shown_key: &mut Option<u64>,
lat_frames: &mut Vec<f64>,
lat_ms: &mut Vec<f64>,
draft_px: &mut Option<[usize; 2]>,
) {
let r = &h.app.renderer;
if let Some(tex) = r.textures.get(&watch())
&& Some(tex.key) != *shown_key
{
*shown_key = Some(tex.key);
if let Some((k, at)) = asked.get(&tex.key) {
lat_frames.push((n - k) as f64);
lat_ms.push(at.elapsed().as_secs_f64() * 1e3);
*draft_px = Some(tex.size);
}
}
}
for i in 1..=ticks {
let v = a + (b - a) * i as f64 / ticks as f64;
// the event arrives at the start of the frame (as input does)
let due = t0 + FRAME * (n + 1);
if let Some(d) = due.checked_duration_since(Instant::now()) {
std::thread::sleep(d);
}
let start = Instant::now();
h.app.run("develop.set", json!({"control": control, "value": v})).unwrap();
n += 1;
h.step();
frame_ms.push(start.elapsed().as_secs_f64() * 1e3);
if let Some(k) = h.app.renderer.wanted(watch()) {
asked.entry(k).or_insert((n, start));
}
observe(&h, n, &asked, &mut shown_key, &mut lat_frames, &mut lat_ms, &mut draft_px);
}
// release
let due = t0 + FRAME * (n + 1);
if let Some(d) = due.checked_duration_since(Instant::now()) {
std::thread::sleep(d);
}
let release = Instant::now();
h.app.run("develop.endInteraction", json!({})).unwrap();
let mut final_ms = f64::NAN;
let mut final_px = None;
while release.elapsed() < Duration::from_secs(60) {
let fm = frame(&mut h, t0, &mut n);
frame_ms.push(fm);
observe(&h, n, &asked, &mut shown_key, &mut lat_frames, &mut lat_ms, &mut draft_px);
let r = &h.app.renderer;
if h.app.session.interaction.is_none()
&& let Some(tex) = r.textures.get(&watch())
&& Some(tex.key) == r.wanted(watch())
&& !r.is_pending(watch())
{
final_ms = release.elapsed().as_secs_f64() * 1e3;
final_px = Some(tex.size);
break;
}
}
let shown = lat_ms.len();
let fmax = frame_ms.iter().cloned().fold(0.0, f64::max);
let lmax = lat_ms.iter().cloned().fold(0.0, f64::max);
println!(
"{control},{shown}/{ticks},{:.0},{:.1},{:.1},{lmax:.1},{:.1},{fmax:.1},{final_ms:.1},{:?},{:?}",
pct(&mut lat_frames, 0.5),
pct(&mut lat_ms, 0.5),
pct(&mut lat_ms, 0.9),
pct(&mut frame_ms, 0.5),
draft_px.unwrap_or([0, 0]),
final_px.unwrap_or([0, 0]),
);
// back to where it was (one undo step), settled
h.app.run("develop.set", json!({"control": control, "value": a})).unwrap();
settle(&mut h, id);
std::thread::sleep(Duration::from_millis(200));
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Opening raw photos in the loupe, timed through the real app logic, headless (no compositor, so no frame
//! throttling) but with the GPU on: import FILES into an in-memory library, open the first in Detail, then step to
//! the next one once its neighbour prefetch is done and once more at once.
//! `cargo run --release -p lightcraft-ui-egui --example open_timeline -- a.ARW b.ARW c.ARW`
//! (`LIGHTCRAFT_PROFILE=1` adds the engine's stage timings; every line carries ms since start on stderr).
use lightcraft_engine::Session;
use lightcraft_ui_egui::{LightcraftApp, Services, headless::Headless};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
fn main() {
let files: Vec<String> = std::env::args().skip(1).collect();
let t0 = Instant::now();
let ms = move || t0.elapsed().as_secs_f64() * 1e3;
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": files})).expect("import");
eprintln!("{:9.1} imported {}", ms(), r["imported"]);
let mut h = Headless::new(LightcraftApp::new(s, Services::default()), [1600.0, 1000.0], 1.0);
let req = |h: &mut Headless, m: &str, p: Value| h.request(m, p, Duration::from_secs(60));
let cmd = |h: &mut Headless, c: &str| req(h, "engine.execute", json!({"command": c}))["result"].clone();
cmd(&mut h, "view.photoGrid");
h.settle(Duration::from_secs(60));
cmd(&mut h, "library.selectNone");
let r = cmd(&mut h, "library.next");
eprintln!("{:9.1} first: {r}", ms());
let first = r["active"].as_u64().unwrap_or(1);
h.settle(Duration::from_secs(60));
let gpu = req(&mut h, "ui.inspect", json!({}))["result"]["perf"]["gpu"].clone();
eprintln!("{:9.1} grid settled, gpu {gpu}", ms());
// what the loupe shows, every frame, until `photo` is rendered for good
let watch = |h: &mut Headless, label: &str, photo: u64| {
let start = ms();
let mut last = String::new();
loop {
let i = req(h, "ui.inspect", json!({}))["result"].clone();
let l = &i["loupe"];
let state = format!("photo={} source={} pending={}", l["photo"], l["source"], l["pending"]);
if state != last {
eprintln!(
"{:9.1} [{label}] +{:7.1} ms {state} (queue {}, in flight {})",
ms(),
ms() - start,
i["perf"]["renderQueue"],
i["perf"]["rendersInFlight"]
);
last = state;
}
let done = l["photo"].as_u64() == Some(photo) && matches!(l["source"].as_str(), Some("render" | "cached")) && l["pending"] == false;
if done || ms() - start > 30_000.0 {
return ms() - start;
}
}
};
eprintln!("{:9.1} open {first}", ms());
cmd(&mut h, "view.detail");
let open = watch(&mut h, "open", first);
let t = ms();
h.settle(Duration::from_secs(60));
eprintln!("{:9.1} neighbours prefetched after {:.0} ms", ms(), ms() - t);
// step towards the side that has photos (the sort may put the first one last)
let mut dir = "library.next";
let mut r = cmd(&mut h, dir);
if r["active"].as_u64() == Some(first) {
dir = "library.previous";
r = cmd(&mut h, dir);
}
eprintln!("{:9.1} {dir}: {r}", ms());
let n1 = r["active"].as_u64().unwrap_or(0);
let step1 = watch(&mut h, "next (prefetched)", n1);
let n2 = cmd(&mut h, dir)["active"].as_u64().unwrap_or(0);
let step2 = watch(&mut h, "next at once", n2);
h.settle(Duration::from_secs(60));
eprintln!("{:9.1} summary: open {open:.0} ms, next prefetched {step1:.0} ms, next at once {step2:.0} ms", ms());
}
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more