Compare commits
298
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e739cd02 | ||
|
|
7317e64d74 | ||
|
|
ceb35048bb | ||
|
|
f731922e16 | ||
|
|
a18a6e6a5e | ||
|
|
ffd91332e9 | ||
|
|
8d96dab1af | ||
|
|
0a4dc3f3ec | ||
|
|
e5ff5461e3 | ||
|
|
cfca299945 | ||
|
|
3af7a0c0ae | ||
|
|
1399c445ef | ||
|
|
a45c2b661b | ||
|
|
c7719ddcbf | ||
|
|
1206d3fd52 | ||
|
|
c435d143de | ||
|
|
82fc317922 | ||
|
|
989422398c | ||
|
|
68e45585d0 | ||
|
|
7dcfbed05c | ||
|
|
d9457e6b23 | ||
|
|
1e1eb5fe16 | ||
|
|
c65065de2f | ||
|
|
3e76713c1d | ||
|
|
ae2f06a035 | ||
|
|
b341101ab6 | ||
|
|
7365d06c13 | ||
|
|
ab73d3d2e0 | ||
|
|
c4c96ecabb | ||
|
|
cae7bea08e | ||
|
|
24615cc2d7 | ||
|
|
d0749ed7a9 | ||
|
|
bb7c46ae3c | ||
|
|
7db3b8a1f9 | ||
|
|
a628d5bc38 | ||
|
|
42f966eec2 | ||
|
|
ca97329ae1 | ||
|
|
3368eb49ee | ||
|
|
cbf9935b6e | ||
|
|
4977c648f6 | ||
|
|
91d317900f | ||
|
|
56754e5703 | ||
|
|
04e8aea577 | ||
|
|
1a2d2ab975 | ||
|
|
1f30856774 | ||
|
|
05769798f6 | ||
|
|
59ab9b6e9b | ||
|
|
111cc893ec | ||
|
|
24f87a91a0 | ||
|
|
20a801d6d2 | ||
|
|
03b665e7a3 | ||
|
|
fd4019300a | ||
|
|
7935999d94 | ||
|
|
6e8db91673 | ||
|
|
7845d515e8 | ||
|
|
cb34bb4842 | ||
|
|
7ca7113b32 | ||
|
|
39de0e6a1c | ||
|
|
3c3b527b7f | ||
|
|
3d9f8492da | ||
|
|
a12f1697a1 | ||
|
|
0ac5cf4430 | ||
|
|
ce7b8f511e | ||
|
|
ed59d78e83 | ||
|
|
a28dc4dbdf | ||
|
|
1ed80c65bf | ||
|
|
b1883231bd | ||
|
|
733846b326 | ||
|
|
8ff45f5237 | ||
|
|
5fefe91640 | ||
|
|
24b80b4ee3 | ||
|
|
922ea25d88 | ||
|
|
085c25b10b | ||
|
|
8036f98aca | ||
|
|
34a4b61f7e | ||
|
|
2c5cb708bb | ||
|
|
39f5f114de | ||
|
|
67aed0b4f4 | ||
|
|
63335e2b0a | ||
|
|
aaae39fae0 | ||
|
|
5443ae28f9 | ||
|
|
4abf70e254 | ||
|
|
79403a5680 | ||
|
|
96a9c6e04d | ||
|
|
e8b18ae636 | ||
|
|
a46c0071a0 | ||
|
|
12188161f9 | ||
|
|
de79310ae0 | ||
|
|
caa102aabd | ||
|
|
373efc280e | ||
|
|
c75389dda1 | ||
|
|
8ee6d63d41 | ||
|
|
f578b27273 | ||
|
|
43419dcfb8 | ||
|
|
83ea5e14df | ||
|
|
eab88ea30c | ||
|
|
3e88a60dd6 | ||
|
|
0cf3ceb438 | ||
|
|
bba4172f83 | ||
|
|
93b900f8d7 | ||
|
|
0cb78071de | ||
|
|
fec379db46 | ||
|
|
4d2f34fb35 | ||
|
|
b306305472 | ||
|
|
832b8ada51 | ||
|
|
bf97105cf7 | ||
|
|
eaaf859853 | ||
|
|
2bd13c1500 | ||
|
|
cc8d617459 | ||
|
|
d3ceaf0e79 | ||
|
|
32cedb3153 | ||
|
|
86d88de2ee | ||
|
|
d01ebd7953 | ||
|
|
54b037a90a | ||
|
|
1e27991cf1 | ||
|
|
7173242ab5 | ||
|
|
31aa638383 | ||
|
|
09d5284e5a | ||
|
|
7e0250e11e | ||
|
|
9087d3ad7f | ||
|
|
a1e99fad9e | ||
|
|
ff9d74f4e5 | ||
|
|
55ecd900ae | ||
|
|
2d47ba3207 | ||
|
|
7b47ba7894 | ||
|
|
826ba91075 | ||
|
|
22b7ba09b9 | ||
|
|
057a8d308e | ||
|
|
1b5ba5eba7 | ||
|
|
ddaea580a1 | ||
|
|
03249f4619 | ||
|
|
495b1ac9fb | ||
|
|
f1a513ffe2 | ||
|
|
a5b9dd603f | ||
|
|
a94b647dfd | ||
|
|
f68cd69b1e | ||
|
|
e9fb4b08e1 | ||
|
|
8a5ecebc18 | ||
|
|
eb06b65fdb | ||
|
|
92196fddeb | ||
|
|
ea33301e6e | ||
|
|
2f7d248d61 | ||
|
|
35c9b67944 | ||
|
|
6a07c35431 | ||
|
|
72bab50c6e | ||
|
|
136b2851ee | ||
|
|
83d0304ce6 | ||
|
|
3255cda67e | ||
|
|
b7f632f5c0 | ||
|
|
63cfc6ab70 | ||
|
|
930d14aacd | ||
|
|
ff9c3fb2ec | ||
|
|
a1d907bf0f | ||
|
|
05618b2ad4 | ||
|
|
4b83f8bb5e | ||
|
|
eebea486d5 | ||
|
|
dc5bff6947 | ||
|
|
f8f091436a | ||
|
|
244088498d | ||
|
|
e0539db3f5 | ||
|
|
2079e8e08d | ||
|
|
b358022f14 | ||
|
|
ca5c212d03 | ||
|
|
14570cfdf3 | ||
|
|
8d79bf082e | ||
|
|
d316d97d80 | ||
|
|
2ab6e973c6 | ||
|
|
c395789026 | ||
|
|
6aa599f869 | ||
|
|
f6f5e6f1b7 | ||
|
|
7094ba1b6e | ||
|
|
cb0e0cb2e5 | ||
|
|
0fb3351c91 | ||
|
|
b9254242a2 | ||
|
|
ab9e8d372c | ||
|
|
b11e5399c6 | ||
|
|
83058f717e | ||
|
|
7962b66a06 | ||
|
|
c140f63372 | ||
|
|
790a341776 | ||
|
|
67b7bc8bc6 | ||
|
|
41e67e1edd | ||
|
|
5d437489b9 | ||
|
|
1e6f0e28e1 | ||
|
|
4af709995f | ||
|
|
9b0d0fd05f | ||
|
|
b1d8ba4284 | ||
|
|
d8efa6a147 | ||
|
|
d4c0a7a3d4 | ||
|
|
68e0827633 | ||
|
|
cad024fbff | ||
|
|
86deb5b004 | ||
|
|
a673b2bb7a | ||
|
|
305ff71474 | ||
|
|
a0e49629d3 | ||
|
|
c8f697a128 | ||
|
|
232cbace63 | ||
|
|
1fa3af8740 | ||
|
|
8e4d7545ff | ||
|
|
1028ed6b88 | ||
|
|
efe7cad73c | ||
|
|
a86defd1b9 | ||
|
|
881bc85dac | ||
|
|
dbe73f0f9c | ||
|
|
0b8c7a237a | ||
|
|
43aa795539 | ||
|
|
38cdd49274 | ||
|
|
0b587a0a8a | ||
|
|
60a98354d9 | ||
|
|
f16f413942 | ||
|
|
48cd9c5e3a | ||
|
|
e2b626b7f7 | ||
|
|
28db7b200b | ||
|
|
3f63db0a0b | ||
|
|
f611e76d0e | ||
|
|
eeb136b9ee | ||
|
|
3f10ba4f3b | ||
|
|
7fb24029f2 | ||
|
|
5601d7879a | ||
|
|
095c7dce87 | ||
|
|
f96f732baa | ||
|
|
bf224c2a8d | ||
|
|
16236ff779 | ||
|
|
2592ab47e9 | ||
|
|
3e791905b9 | ||
|
|
2897f85861 | ||
|
|
3517606ffc | ||
|
|
9e51ca0da8 | ||
|
|
85e38c7342 | ||
|
|
4836143f9e | ||
|
|
57dc811a6a | ||
|
|
9e327b4772 | ||
|
|
f6fcef4d5c | ||
|
|
3895cf6411 | ||
|
|
5cb74e7313 | ||
|
|
4165904bd1 | ||
|
|
6ea9b979c8 | ||
|
|
e73d88a55f | ||
|
|
d56d265044 | ||
|
|
b4a1166f4b | ||
|
|
5413c8bb3a | ||
|
|
c07395222f | ||
|
|
7b082660e7 | ||
|
|
3a9b6ccee4 | ||
|
|
07d9678209 | ||
|
|
8980c3bb0e | ||
|
|
3c76902bbb | ||
|
|
1ebce934ea | ||
|
|
7cf3719c63 | ||
|
|
c3dce49c2e | ||
|
|
3af89e6ed5 | ||
|
|
a7fcd81850 | ||
|
|
c3c30dae3f | ||
|
|
47cf1ade6f | ||
|
|
00388f3fd2 | ||
|
|
1b4518c202 | ||
|
|
d16ab1f238 | ||
|
|
bbcfdf085b | ||
|
|
1e6035d9db | ||
|
|
37cfc46e0d | ||
|
|
b256b8b635 | ||
|
|
e8c817ddea | ||
|
|
d367d83ee6 | ||
|
|
6a05f865ee | ||
|
|
866d02e577 | ||
|
|
4a427894ed | ||
|
|
aaa46003d5 | ||
|
|
a9cd761d52 | ||
|
|
68267810b3 | ||
|
|
9b4ba1aa80 | ||
|
|
0634782fbc | ||
|
|
9b5e771fd8 | ||
|
|
69384ad430 | ||
|
|
acc85c4147 | ||
|
|
e2d35de867 | ||
|
|
0fbf20db51 | ||
|
|
2b66eb4800 | ||
|
|
92c7237c50 | ||
|
|
34ecea4f24 | ||
|
|
ff8d027766 | ||
|
|
8b70d33d0f | ||
|
|
5e2ece0cf3 | ||
|
|
f2bcc9219c | ||
|
|
0613f925de | ||
|
|
853ada7754 | ||
|
|
b4ca9f7439 | ||
|
|
cfcc169ff1 | ||
|
|
5923be31d6 | ||
|
|
35dd973d91 | ||
|
|
49868693d5 | ||
|
|
79fbb6b464 | ||
|
|
95b57d3786 | ||
|
|
782ade6c39 | ||
|
|
11d6676984 | ||
|
|
841850d516 | ||
|
|
6c7405ed3e | ||
|
|
6a34a1f8b8 | ||
|
|
8148988eb8 |
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese fonts come from craft-fonts (below).
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
|
||||
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese fonts come from craft-fonts (below).
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
|
||||
Generated
+378
-33
@@ -209,7 +209,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
"equator 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -967,6 +967,12 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coe-rs"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e8f1e641542c07631228b1e0dc04b69ae3c1d58ef65d5691a439711d805c698"
|
||||
|
||||
[[package]]
|
||||
name = "color"
|
||||
version = "0.3.3"
|
||||
@@ -1256,6 +1262,16 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-stack"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e53799688f5632f364f8fb387488dd05db9fe45db7011be066fc20e7027f8b"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"reborrow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-stack"
|
||||
version = "0.13.2"
|
||||
@@ -1537,13 +1553,33 @@ version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3b0a88aa91d0ad2b9684e4479aed31a17d3f9051bdbbc634bd2c01bc5a5eee8"
|
||||
dependencies = [
|
||||
"equator-macro 0.1.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
"equator-macro 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60d08acb9849f7fb4401564f251be5a526829183a3645a90197dea8e786cf3ae"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1608,6 +1644,42 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faer-core"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da0f6262445261106407bdfb548df4f8f1c89bcc3cff1bb6d08e7f525b575a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"coe-rs",
|
||||
"dyn-stack 0.10.0",
|
||||
"equator 0.1.10",
|
||||
"faer-entity",
|
||||
"gemm 0.17.1",
|
||||
"matrixcompare-core",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"pulp 0.18.22",
|
||||
"reborrow",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faer-entity"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fb12f36da3162b3fefc0c7343d905d5bd2c2bf444a7f63ceeaa2bc003311d3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"coe-rs",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"pulp 0.18.22",
|
||||
"reborrow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1783,13 +1855,33 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ab24cc62135b40090e31a76a9b2766a501979f3070fa27f689c27ec04377d32"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-c32 0.17.1",
|
||||
"gemm-c64 0.17.1",
|
||||
"gemm-common 0.17.1",
|
||||
"gemm-f16 0.17.1",
|
||||
"gemm-f32 0.17.1",
|
||||
"gemm-f64 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-c32 0.18.2",
|
||||
"gemm-c64 0.18.2",
|
||||
"gemm-common 0.18.2",
|
||||
@@ -1799,7 +1891,7 @@ dependencies = [
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1809,7 +1901,7 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-c32 0.19.0",
|
||||
"gemm-c64 0.19.0",
|
||||
"gemm-common 0.19.0",
|
||||
@@ -1819,7 +1911,22 @@ dependencies = [
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c32"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9c030d0b983d1e34a546b86e08f600c11696fde16199f971cd46c12e67512c0"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1829,12 +1936,12 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1844,12 +1951,27 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c64"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb5f2e79fefb9693d18e1066a557b4546cd334b226beadc68b11a8f9431852a"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1859,12 +1981,12 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1874,15 +1996,34 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2e7ea062c987abcd8db95db917b4ffb4ecdfd0668471d8dc54734fdff2354e8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack 0.10.0",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.18.22",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
"sysctl 0.5.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.18.2"
|
||||
@@ -1890,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"half",
|
||||
"libm",
|
||||
"num-complex",
|
||||
@@ -1898,10 +2039,10 @@ dependencies = [
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.21.5",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
"sysctl 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1911,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"half",
|
||||
"libm",
|
||||
"num-complex",
|
||||
@@ -1919,10 +2060,27 @@ dependencies = [
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.22.3",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
"sysctl 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f16"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca4c06b9b11952071d317604acb332e924e817bd891bec8dfb494168c7cedd4"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"gemm-f32 0.17.1",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1931,14 +2089,14 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"gemm-f32 0.18.2",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
]
|
||||
@@ -1949,30 +2107,45 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"gemm-f32 0.19.0",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9a69f51aaefbd9cf12d18faf273d3e982d9d711f60775645ed5c8047b4ae113"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1982,12 +2155,27 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f64"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa397a48544fadf0b81ec8741e5c0fba0043008113f71f2034def1935645d2b0"
|
||||
dependencies = [
|
||||
"dyn-stack 0.10.0",
|
||||
"gemm-common 0.17.1",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid 10.7.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -1997,12 +2185,12 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -2012,12 +2200,12 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"dyn-stack 0.13.2",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
@@ -2296,6 +2484,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2837,6 +3034,7 @@ dependencies = [
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-raster",
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
@@ -2859,6 +3057,38 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.4.0"
|
||||
@@ -2881,7 +3111,11 @@ dependencies = [
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-denoise-core",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-merge",
|
||||
@@ -2892,12 +3126,28 @@ dependencies = [
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-segment",
|
||||
"lightcraft-tiff",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
@@ -2926,6 +3176,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
@@ -2936,6 +3187,13 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.4.0"
|
||||
@@ -2984,6 +3242,7 @@ dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"proptest",
|
||||
"rayon",
|
||||
@@ -3020,6 +3279,7 @@ name = "lightcraft-raw"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
@@ -3029,9 +3289,11 @@ dependencies = [
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3079,8 +3341,10 @@ dependencies = [
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
@@ -3171,6 +3435,22 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matrixcompare-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0bdabb30db18805d5290b3da7ceaccbddba795620b86c02145d688e04900a73"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -3324,6 +3604,21 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -4288,6 +4583,18 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.18.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"reborrow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.21.5"
|
||||
@@ -4314,7 +4621,7 @@ dependencies = [
|
||||
"num-complex",
|
||||
"paste",
|
||||
"pulp-wasm-simd-flag",
|
||||
"raw-cpuid",
|
||||
"raw-cpuid 11.6.0",
|
||||
"reborrow",
|
||||
"version_check",
|
||||
]
|
||||
@@ -4515,6 +4822,15 @@ dependencies = [
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "10.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
@@ -4542,6 +4858,12 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
@@ -5242,6 +5564,20 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"enum-as-inner",
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
@@ -6749,6 +7085,15 @@ dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpegxl"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
|
||||
@@ -11,7 +11,10 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-faces = { path = "crates/faces" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
@@ -30,10 +33,12 @@ lightcraft-engine = { path = "crates/engine" }
|
||||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
@@ -61,6 +66,7 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
|
||||
@@ -27,6 +27,15 @@ the facebook/sam3 checkpoint only when they choose to (in the app, or by hand),
|
||||
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
|
||||
licence. See docs/ai-masks.md.
|
||||
|
||||
AI denoise weights are not part of LightCraft and are never distributed with it.
|
||||
The default build offers user-supplied models only. The optional rawnind-model
|
||||
feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under
|
||||
GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms.
|
||||
Commercial use is subject to those terms, including applicable source, licence and
|
||||
notice obligations on redistribution. Enabling that offer in releases remains a
|
||||
maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implementation;
|
||||
no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
|
||||
|
||||
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
|
||||
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
|
||||
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
|
||||
|
||||
@@ -258,9 +258,10 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Fast, native, private
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today.
|
||||
PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
|
||||
as in PhotoCraft).
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
@@ -288,8 +289,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
|
||||
older-Canon raws on one machine.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
|
||||
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **HDR, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
@@ -302,9 +303,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
|
||||
@@ -353,13 +354,14 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
(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
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the
|
||||
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
|
||||
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
|
||||
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
|
||||
|
||||
+43
-25
@@ -6,7 +6,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
|
||||
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
|
||||
@@ -23,13 +23,13 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
@@ -37,18 +37,20 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
|
||||
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
|
||||
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open |
|
||||
|
||||
## Where we're going
|
||||
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
|
||||
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
|
||||
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); then ORF, then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
|
||||
@@ -57,9 +59,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
(references stay in the local `plan/`), then tune against the numbers.
|
||||
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. Object / Describe masks now
|
||||
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
|
||||
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People and Enhance are still open.
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
|
||||
@@ -84,18 +86,18 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
|
||||
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
|
||||
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
|
||||
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
|
||||
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise, Raw Details, Super Resolution ⬜) |
|
||||
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
@@ -122,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
@@ -169,31 +171,46 @@ The milestone estimates in the table above were made before work started and are
|
||||
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
|
||||
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
|
||||
|
||||
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
|
||||
X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
|
||||
lossy ones decode but no real file has verified them), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
|
||||
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
|
||||
it can't decode yet.
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Lightroom Classic catalog migration
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
|
||||
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
|
||||
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
|
||||
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
|
||||
@@ -202,6 +219,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
|
||||
@@ -25,7 +25,14 @@ serde_json = { workspace = true }
|
||||
dhat = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["denoise"]
|
||||
denoise = ["lightcraft-engine/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
|
||||
# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts
|
||||
# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json
|
||||
# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work.
|
||||
dhat-heap = ["dep:dhat"]
|
||||
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
@@ -89,7 +89,7 @@ USAGE:
|
||||
lightcraft-cli controls [--json] list every develop control id with its range
|
||||
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
|
||||
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
|
||||
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
|
||||
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
|
||||
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
|
||||
take their colour from the profile and only their tone from their own JPEG.
|
||||
@@ -102,6 +102,14 @@ static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr:
|
||||
/// stdout carries command results / MCP protocol messages only.
|
||||
fn library_warnings(session: &mut Session, who: &str) {
|
||||
for w in session.take_library_warnings() {
|
||||
eprintln!("{who}: warning: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
@@ -159,7 +167,8 @@ fn main() -> ExitCode {
|
||||
|
||||
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
|
||||
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
|
||||
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
|
||||
let is_raw =
|
||||
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
|
||||
if path.is_file() {
|
||||
if is_raw(path) {
|
||||
out.push(path.to_path_buf());
|
||||
@@ -316,6 +325,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
library_warnings(&mut h.session, "lightcraft-cli mcp");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -381,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models();
|
||||
let paths = expand_paths(&files);
|
||||
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
@@ -552,6 +562,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
library_warnings(&mut h.session, "lightcraft-cli");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -641,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let input = input.ok_or("render: missing input file")?;
|
||||
let output = output.ok_or("render: missing -o OUTPUT")?;
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models();
|
||||
let abs = expand_paths(std::slice::from_ref(&input));
|
||||
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
|
||||
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
|
||||
@@ -717,19 +728,19 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
if script.is_none() && output.is_none() {
|
||||
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
|
||||
}
|
||||
if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
|
||||
return Err("snapshot: bad --size/--scale".into());
|
||||
}
|
||||
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
|
||||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_default_face_models();
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
None => Session::new().with_fs(),
|
||||
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models(),
|
||||
None => Session::new().with_fs().with_default_denoise_models().with_default_face_models(),
|
||||
};
|
||||
// Apply compute policy before imports or the first scripted query can discover an adapter.
|
||||
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
|
||||
if !files.is_empty() {
|
||||
let ti = Instant::now();
|
||||
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
|
||||
@@ -744,7 +755,11 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
// Snapshot sessions promise no GPU: disable photo compute as well as the compositor
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
|
||||
@@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) {
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrate_reads_raf_inputs_and_reports_bad_files() {
|
||||
let input = tmp("invalid.RAF");
|
||||
std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap();
|
||||
let out = tmp("raf-profiles");
|
||||
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}");
|
||||
assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}");
|
||||
assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
|
||||
}
|
||||
|
||||
fn mean(path: &std::path::Path) -> f64 {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
|
||||
let img = d.to_srgb8();
|
||||
@@ -154,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"# comment\n{}\n{}\n{}\n{}\n",
|
||||
"# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
// (settled: under load a grid shot taken before its thumbnails is darker than the
|
||||
// dimmed export dialog over them, and the comparison below fails)
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot"}),
|
||||
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
|
||||
),
|
||||
)
|
||||
@@ -168,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4);
|
||||
assert_eq!(replies.len(), 6);
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
for (p, dimmed) in [(&a, false), (&b, true)] {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
|
||||
@@ -216,6 +233,58 @@ fn snapshot_script_failure_exits_non_zero() {
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
|
||||
let script = tmp("snap-ui-zoom.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
|
||||
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
|
||||
let o = Command::new(BIN)
|
||||
.args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert!(replies[1]["result"]["pixelsPerPoint"].as_f64().unwrap() > scale.parse::<f64>().unwrap());
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless snapshots default to CPU photo rendering as well as CPU UI rasterization.
|
||||
#[test]
|
||||
fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
|
||||
let script = tmp("default-cpu.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})),
|
||||
)
|
||||
.unwrap();
|
||||
let image = tmp("default-cpu.png");
|
||||
let output = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
let gpu = &replies[0]["result"];
|
||||
assert_eq!(gpu["enabled"], false, "{gpu}");
|
||||
assert_eq!(gpu["available"], false, "{gpu}");
|
||||
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (480, 320));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
@@ -395,3 +464,79 @@ fn warnings_are_logged_on_stderr() {
|
||||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
for (index, (size, scale)) in
|
||||
[("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
|
||||
{
|
||||
let image = tmp(&format!("invalid-viewport-{index}.png"));
|
||||
let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!image.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_fractional_scale_rounding() {
|
||||
let image = tmp("fractional-scale-rounding.png");
|
||||
let output =
|
||||
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (311, 180));
|
||||
}
|
||||
|
||||
/// Snapshot's CPU setting must never replace the desktop preference, including with --library.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
let dir = tmp("snapshot-desktop-preferences");
|
||||
let library = dir.join("library");
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(&library, true).unwrap();
|
||||
drop(session);
|
||||
let config = dir.join("config");
|
||||
let app_config = config.join("lightcraft");
|
||||
std::fs::create_dir_all(&app_config).unwrap();
|
||||
let ui_path = app_config.join("ui.json");
|
||||
let mut ui = lightcraft_ui_egui::UiState::default();
|
||||
ui.settings.gpu = true;
|
||||
let saved = serde_json::to_vec_pretty(&ui).unwrap();
|
||||
std::fs::write(&ui_path, &saved).unwrap();
|
||||
let script = dir.join("preferences.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
for gpu_environment in [None, Some("1")] {
|
||||
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
|
||||
if let Some(value) = gpu_environment {
|
||||
cmd.env("LIGHTCRAFT_GPU", value);
|
||||
}
|
||||
let output = cmd
|
||||
.args(["snapshot", "--library"])
|
||||
.arg(&library)
|
||||
.args(["--script"])
|
||||
.arg(&script)
|
||||
.args(["-o"])
|
||||
.arg(&image)
|
||||
.args(["--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert_eq!(replies[0]["result"]["enabled"], false);
|
||||
assert_eq!(replies[0]["result"]["available"], false);
|
||||
assert_eq!(replies[0]["result"]["adapter"], Value::Null);
|
||||
assert_eq!(replies[1]["result"]["ui"]["settings"]["gpu"], false);
|
||||
assert_eq!(std::fs::read(&ui_path).unwrap(), saved, "snapshot changed the desktop preferences");
|
||||
assert!(!app_config.join("ui.json.tmp").exists());
|
||||
assert!(image.exists());
|
||||
}
|
||||
}
|
||||
@@ -71,3 +71,9 @@ web-sys = { workspace = true, features = [
|
||||
"WorkerType",
|
||||
"WritableStream",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it. On wasm32 panics abort, so
|
||||
# heic-rs's `catch_unwind` guard does not apply there.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
@@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
picker: None,
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
@@ -204,9 +205,13 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
open_picker(originals.clone(), backend.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
})),
|
||||
pick_denoise_model: None,
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
// Face models need a folder to live in; the web has none.
|
||||
pick_model_file: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
@@ -217,6 +222,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
reveal: None,
|
||||
log_file: None,
|
||||
open_with: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -53,6 +53,9 @@ pub struct WireJob {
|
||||
/// The request's diagnostic overlay (`Overlay::to_parts`).
|
||||
#[serde(default)]
|
||||
pub overlay: (u8, f64),
|
||||
/// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view).
|
||||
#[serde(default)]
|
||||
pub window: Option<[usize; 4]>,
|
||||
}
|
||||
|
||||
impl WireJob {
|
||||
@@ -83,6 +86,7 @@ impl WireJob {
|
||||
thumb_cached,
|
||||
stages: job.stages.is_some().then(|| view.to_string()),
|
||||
overlay: job.request.overlay.to_parts(),
|
||||
window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +112,7 @@ impl WireJob {
|
||||
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
proof: None,
|
||||
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +353,40 @@ mod tests {
|
||||
assert!(reopened.contains(&new));
|
||||
}
|
||||
|
||||
// Issue #323: a worker given a zoomed view's window renders that window, not the whole frame
|
||||
#[test]
|
||||
fn a_window_job_crosses_the_wire_and_renders_the_same_window() {
|
||||
use lightcraft_engine::pipeline::PixelWindow;
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 };
|
||||
let job = s.region_job(id, 1920, 1280, win, true).unwrap();
|
||||
let wire = WireJob::from_job(&job, false, "Region");
|
||||
let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
|
||||
assert_eq!(wire.request().window, Some(win));
|
||||
let mut core = WorkerCore::default();
|
||||
let a = core.render(&wire, None).unwrap();
|
||||
let b = job.run().rendered.unwrap();
|
||||
assert_eq!((a.image.width, a.image.height), (160, 120));
|
||||
assert_eq!((b.image.width, b.image.height), (160, 120));
|
||||
// the worker renders on the CPU; the engine job uses a GPU when the machine has one, which
|
||||
// stays within docs/gpu-pipeline.md's bound (≤ 3 LSB per channel) rather than bit-exact
|
||||
let max = a.image.data.iter().zip(&b.image.data).flat_map(|(p, q)| (0..3).map(move |c| p[c].abs_diff(q[c]))).max();
|
||||
assert!(max.is_some_and(|m| m <= 3), "max channel difference {max:?}");
|
||||
}
|
||||
|
||||
// a job from before windows existed has none
|
||||
#[test]
|
||||
fn a_job_without_a_window_field_has_no_window() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb");
|
||||
let mut v = serde_json::to_value(&wire).unwrap();
|
||||
v.as_object_mut().unwrap().remove("window");
|
||||
let old: WireJob = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.request().window, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_job_renders_like_the_engine() {
|
||||
let mut s = Session::with_demo();
|
||||
@@ -393,6 +432,7 @@ mod tests {
|
||||
thumb_cached: false,
|
||||
stages: Some("Main".into()),
|
||||
overlay: (0, 0.0),
|
||||
window: None,
|
||||
};
|
||||
let mut core = WorkerCore::default();
|
||||
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));
|
||||
|
||||
@@ -9,6 +9,14 @@ default-run = "lightcraft"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["denoise"]
|
||||
denoise = ["lightcraft-engine/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true, features = ["sam"] }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! File-dialog filters that match extensions whatever their letter case.
|
||||
//!
|
||||
//! On Linux, `rfd` turns each extension into a `*.ext` glob for the GTK dialog and for the
|
||||
//! desktop portal (`xdg-desktop-portal` FileChooser), and both match globs case-sensitively, so
|
||||
//! a filter of `nef` hid `DSC_1234.NEF` (issue #342). Windows and macOS ignore case. Every
|
||||
//! dialog filter of the app goes through [`FileDialogExt::add_filter_nocase`] (a test checks
|
||||
//! that nothing else calls `add_filter`), so no extension can hit this.
|
||||
|
||||
use rfd::FileDialog;
|
||||
|
||||
/// The patterns to hand to `rfd` for `exts`, which are plain extensions without the dot.
|
||||
///
|
||||
/// Windows and macOS take the extension literally and ignore case: lower case, de-duplicated.
|
||||
/// Linux (`linux`): `rfd` formats each entry as `*.{entry}`, which GTK 3 and the portal match
|
||||
/// as a glob, so an entry may carry glob syntax. Each extension gives the lower-case form, the
|
||||
/// UPPER-case form (kept in case a portal backend does not do `[...]` classes) and a bracket
|
||||
/// form such as `[nN][eE][fF]` that matches every mix (`.Nef`, `.nEf`, ...).
|
||||
pub fn dialog_extensions_for(exts: &[&str], linux: bool) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |s: String| {
|
||||
if !s.is_empty() && !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
};
|
||||
for ext in exts {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
let lower = ext.to_lowercase();
|
||||
push(lower.clone());
|
||||
if !linux {
|
||||
continue;
|
||||
}
|
||||
push(ext.to_uppercase());
|
||||
// `[` `]` `*` `?` in an extension would already be glob syntax: leave those alone
|
||||
if lower.chars().any(|c| c.is_alphabetic()) && !lower.contains(['[', ']', '*', '?', '\\']) {
|
||||
push(
|
||||
lower
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let up: String = c.to_uppercase().collect();
|
||||
if up.chars().count() == 1 && up != c.to_string() { format!("[{c}{up}]") } else { c.to_string() }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`dialog_extensions_for`] for the platform this is built for.
|
||||
pub fn dialog_extensions(exts: &[&str]) -> Vec<String> {
|
||||
dialog_extensions_for(exts, cfg!(all(unix, not(target_os = "macos"))))
|
||||
}
|
||||
|
||||
/// `add_filter` that matches the extensions in any letter case.
|
||||
pub trait FileDialogExt {
|
||||
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self;
|
||||
}
|
||||
|
||||
impl FileDialogExt for FileDialog {
|
||||
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self {
|
||||
self.add_filter(name, &dialog_extensions(exts))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn other_platforms_get_lower_case_once() {
|
||||
assert_eq!(dialog_extensions_for(&["NEF", "nef", ".Jpg", "jpg", ""], false), ["nef", "jpg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_gets_lower_upper_and_any_mix() {
|
||||
assert_eq!(dialog_extensions_for(&["nef"], true), ["nef", "NEF", "[nN][eE][fF]"]);
|
||||
// digits have no case; a duplicate adds nothing
|
||||
assert_eq!(dialog_extensions_for(&["mp4", "MP4"], true), ["mp4", "MP4", "[mM][pP]4"]);
|
||||
assert_eq!(dialog_extensions_for(&["7z"], true), ["7z", "7Z", "7[zZ]"]);
|
||||
assert_eq!(dialog_extensions_for(&["123"], true), ["123"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_glob_syntax_is_not_wrapped_again() {
|
||||
assert_eq!(dialog_extensions_for(&["[nN]ef"], true), ["[nn]ef", "[NN]EF"]);
|
||||
}
|
||||
|
||||
/// The Linux patterns match like GTK's glob (`*.{entry}`): checked against a tiny matcher of
|
||||
/// the subset used here (verified against real GTK 3.24 `GtkFileFilter` in the PR notes).
|
||||
fn glob_ext(entry: &str, ext: &str) -> bool {
|
||||
let mut pat = entry.chars().peekable();
|
||||
let mut s = ext.chars();
|
||||
while let Some(p) = pat.next() {
|
||||
let Some(c) = s.next() else { return false };
|
||||
if p == '[' {
|
||||
let mut hit = false;
|
||||
for q in pat.by_ref() {
|
||||
if q == ']' {
|
||||
break;
|
||||
}
|
||||
hit |= q == c;
|
||||
}
|
||||
if !hit {
|
||||
return false;
|
||||
}
|
||||
} else if p != c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
s.next().is_none()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_patterns_match_every_case_mix() {
|
||||
let pats = dialog_extensions_for(&["nef", "cr2", "xmp"], true);
|
||||
let matches = |name: &str| pats.iter().any(|p| glob_ext(p, name));
|
||||
for ok in ["nef", "NEF", "Nef", "nEF", "CR2", "Cr2", "XMP", "xMp"] {
|
||||
assert!(matches(ok), "{ok}");
|
||||
}
|
||||
for bad in ["nex", "ne", "nefs", "jpg", "cr3"] {
|
||||
assert!(!matches(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's own extension list (the one the folder scan uses) is covered whole.
|
||||
#[test]
|
||||
fn import_extensions_are_all_covered_in_both_cases() {
|
||||
let pats = dialog_extensions_for(lightcraft_engine::import::EXTENSIONS, true);
|
||||
for e in lightcraft_engine::import::EXTENSIONS {
|
||||
assert!(pats.iter().any(|p| glob_ext(p, &e.to_uppercase())), "{e}");
|
||||
assert!(pats.iter().any(|p| p == e), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog filter goes through the helper: `main.rs` must not call rfd's own
|
||||
/// `add_filter`.
|
||||
#[test]
|
||||
fn no_dialog_bypasses_the_helper() {
|
||||
for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] {
|
||||
assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase");
|
||||
}
|
||||
assert!(include_str!("main.rs").contains(".add_filter_nocase("));
|
||||
}
|
||||
}
|
||||
+189
-24
@@ -24,10 +24,12 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
mod dialog_filter;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
@@ -123,7 +125,7 @@ fn gpu_crash_notice(what: &str) -> String {
|
||||
}
|
||||
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::camera_profiles::config_dir()
|
||||
lightcraft_engine::config::config_dir()
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
@@ -269,8 +271,61 @@ impl PrefsWriter {
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
/// The Windows command that opens `url` in the default browser. `explorer <url>` opened File
|
||||
/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
|
||||
/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
|
||||
/// `?`, `&` and `#` need no escaping.
|
||||
fn windows_open_url_command(url: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("rundll32");
|
||||
c.args(["url.dll,FileProtocolHandler", url]);
|
||||
c
|
||||
}
|
||||
|
||||
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
|
||||
/// for Help ▸ Open Log Folder.
|
||||
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
|
||||
Services {
|
||||
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
|
||||
// stopped the window answering the compositor, which then reported the app as hung.
|
||||
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
|
||||
use lightcraft_ui_egui::pick::PickKind;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let repaint = ctx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
let mut d = rfd::FileDialog::new();
|
||||
if let Some(t) = &req.title {
|
||||
d = d.set_title(t);
|
||||
}
|
||||
if let Some((name, exts)) = &req.filter {
|
||||
d = d.add_filter_nocase(name.clone(), exts);
|
||||
}
|
||||
if let Some(n) = &req.file_name {
|
||||
d = d.set_file_name(n);
|
||||
}
|
||||
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
|
||||
let paths: Vec<String> = match req.kind {
|
||||
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
|
||||
PickKind::File => d.pick_file().map(s).into_iter().collect(),
|
||||
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
|
||||
PickKind::Save => d.save_file().map(s).into_iter().collect(),
|
||||
};
|
||||
// the app may have quit meanwhile: nobody to tell
|
||||
let _ = tx.send(paths);
|
||||
repaint.request_repaint();
|
||||
})
|
||||
.map_err(|e| format!("could not start the file dialog: {e}"))?;
|
||||
Ok(rx)
|
||||
})),
|
||||
log_file: log_file.map(|p| p.display().to_string()),
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
@@ -303,7 +358,7 @@ fn services() -> Services {
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").arg(url).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
std::process::Command::new("explorer").arg(url).status()
|
||||
windows_open_url_command(url).status()
|
||||
} else {
|
||||
std::process::Command::new("xdg-open").arg(url).status()
|
||||
};
|
||||
@@ -325,23 +380,24 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
lightcraft_ui_egui::i18n::tr("Photos"),
|
||||
&[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
|
||||
"pef", "psd", "jxl", "gif", "bmp",
|
||||
],
|
||||
)
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_denoise_model: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().into_owned()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter(
|
||||
.add_filter_nocase(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
||||
)
|
||||
@@ -351,10 +407,18 @@ fn services() -> Services {
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_model_file: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
@@ -362,7 +426,7 @@ fn services() -> Services {
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -370,7 +434,7 @@ fn services() -> Services {
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -380,7 +444,7 @@ fn services() -> Services {
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -397,22 +461,29 @@ fn services() -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// The library session with the folder face models are kept in (`<config>/models`).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
match s.open_library(&dir, seed_demo) {
|
||||
Ok(r) => {
|
||||
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||
@@ -456,6 +527,31 @@ ENVIRONMENT:
|
||||
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
|
||||
";
|
||||
|
||||
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
|
||||
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
|
||||
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
|
||||
launch_imports_in(args, cwd.as_deref(), home.as_deref())
|
||||
}
|
||||
|
||||
fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
|
||||
use lightcraft_engine::import::{LaunchPath, launch_path};
|
||||
let (mut files, mut notices) = (Vec::new(), Vec::new());
|
||||
for a in args {
|
||||
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
|
||||
Some(LaunchPath::Import(p)) => files.push(p),
|
||||
Some(LaunchPath::Refused { path, why }) => {
|
||||
log::warn!("not importing {path} given at launch: {why}");
|
||||
notices
|
||||
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
(files, notices)
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
@@ -513,18 +609,29 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
|
||||
// no file behind. Records logged until now are written to it first.
|
||||
let mut log_file = None;
|
||||
if let Some(logger) = logger {
|
||||
// a --memory session writes nothing (issue #164), a log file included
|
||||
match log_dir().filter(|_| !in_memory) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
Ok(path) => {
|
||||
log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display());
|
||||
log_file = Some(path);
|
||||
}
|
||||
// The file sink has given up by now, so this goes to standard error only.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
// what the arguments name, resolved now: an empty or relative argument never becomes a drive
|
||||
// root, and a whole drive or home folder is not walked (issue #374)
|
||||
let (files, launch_notices) = launch_imports(&files);
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// the saved language from the start, so a failed start is reported in it too
|
||||
if let Some(ui) = &prefs {
|
||||
lightcraft_ui_egui::i18n::set_language(ui.language);
|
||||
}
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -554,7 +661,9 @@ fn main() -> eframe::Result {
|
||||
wgpu_options: window_wgpu_options(),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
// the app's copy: `log_file` is still named in the message of a start that fails
|
||||
let app_log_file = log_file.clone();
|
||||
let started = eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
@@ -565,13 +674,14 @@ fn main() -> eframe::Result {
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
@@ -594,7 +704,12 @@ fn main() -> eframe::Result {
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
@@ -608,7 +723,28 @@ fn main() -> eframe::Result {
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
);
|
||||
if let Err(e) = &started {
|
||||
startup_failed(&e.to_string(), log_file.as_deref());
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
|
||||
/// message box naming the log file, instead of exiting without a trace — the release builds have
|
||||
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
|
||||
fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
|
||||
log::error!("LightCraft could not open its window: {error}");
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return; // tests and scripts: no dialog to click away
|
||||
}
|
||||
let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title(title)
|
||||
.set_description(text)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -623,6 +759,24 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #374: launch arguments that would walk a whole drive or home folder are reported,
|
||||
/// not imported; empty ones are ignored; the rest are imported as absolute paths.
|
||||
#[test]
|
||||
fn launch_arguments_never_walk_a_drive_or_the_home_folder() {
|
||||
let home = dir("launch");
|
||||
std::fs::create_dir_all(home.join("Trip")).unwrap();
|
||||
let args: Vec<String> = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect();
|
||||
let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home));
|
||||
assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]);
|
||||
assert_eq!(notices.len(), 2, "{notices:?}");
|
||||
assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}");
|
||||
// the working folder is a drive root (a launcher's choice): `.` is never the whole drive
|
||||
let root = home.ancestors().last().unwrap().to_path_buf();
|
||||
let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None);
|
||||
assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}");
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
@@ -744,6 +898,17 @@ mod tests {
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
fn windows_links_open_in_the_browser() {
|
||||
let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200";
|
||||
let c = windows_open_url_command(url);
|
||||
assert_eq!(c.get_program(), "rundll32");
|
||||
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
|
||||
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_is_not_a_number_is_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct NativeMenu {
|
||||
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
|
||||
structure: String,
|
||||
text_focus: bool,
|
||||
/// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
|
||||
/// press reaches it.
|
||||
capturing: bool,
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
|
||||
@@ -211,7 +214,7 @@ impl NativeMenu {
|
||||
let _ = tx.send(e.id.0);
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
|
||||
m.rebuild(app);
|
||||
app.native_menu = true;
|
||||
m
|
||||
@@ -281,6 +284,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.menu.init_for_nsapp();
|
||||
self.text_focus = false;
|
||||
self.capturing = false;
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
|
||||
@@ -345,7 +349,8 @@ impl NativeMenu {
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
|
||||
// (none while the keymap editor records a shortcut: every key goes to it)
|
||||
app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
@@ -407,14 +412,16 @@ impl NativeMenu {
|
||||
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
|
||||
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
if focus != self.text_focus {
|
||||
let capturing = app.recording_shortcut.is_some();
|
||||
if focus != self.text_focus || capturing != self.capturing {
|
||||
self.text_focus = focus;
|
||||
self.capturing = capturing;
|
||||
for it in self.items.values() {
|
||||
let Some(sc) = it.shortcut.as_deref() else { continue };
|
||||
if it.accel.is_none() || !yields_to_text(sc) {
|
||||
if it.accel.is_none() {
|
||||
continue;
|
||||
}
|
||||
let accel = if focus { None } else { it.accel };
|
||||
let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
|
||||
let _ = match &it.handle {
|
||||
Handle::Plain(h) => h.set_accelerator(accel),
|
||||
Handle::Check(h) => h.set_accelerator(accel),
|
||||
|
||||
@@ -12,6 +12,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -21,3 +21,6 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
@@ -45,6 +45,7 @@
|
||||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 1 | up to v0.2.0 | |
|
||||
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
|
||||
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -69,7 +70,7 @@ pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 2;
|
||||
pub const VERSION: u32 = 3;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -136,6 +136,11 @@ pub enum Op {
|
||||
id: AlbumId,
|
||||
parent: Option<AlbumId>,
|
||||
},
|
||||
/// Where the album stands among its siblings (`None`: by name). Format version 3.
|
||||
SetAlbumOrder {
|
||||
id: AlbumId,
|
||||
order: Option<u32>,
|
||||
},
|
||||
SetAlbumPhotos {
|
||||
id: AlbumId,
|
||||
photos: Vec<PhotoId>,
|
||||
@@ -282,6 +287,28 @@ impl Catalog {
|
||||
pub fn albums(&self) -> impl Iterator<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
/// The albums and folders directly inside `parent` (`None`: the top level), in the order the
|
||||
/// sidebar lists them: folders first, then by their place if the user ordered them (those
|
||||
/// without one after, by name), else by name.
|
||||
pub fn album_children(&self, parent: Option<AlbumId>) -> Vec<&Album> {
|
||||
let mut kids: Vec<&Album> = self.albums.values().filter(|a| a.parent == parent).collect();
|
||||
kids.sort_by_cached_key(|a| album_order_key(a));
|
||||
kids
|
||||
}
|
||||
/// [`Self::album_children`] of every folder (and of the top level, under `None`) in one pass:
|
||||
/// what a tree view that draws all of them wants each frame.
|
||||
pub fn album_children_by_parent(&self) -> std::collections::HashMap<Option<AlbumId>, Vec<&Album>> {
|
||||
let mut map: std::collections::HashMap<Option<AlbumId>, Vec<&Album>> = std::collections::HashMap::new();
|
||||
for a in self.albums.values() {
|
||||
map.entry(a.parent).or_default().push(a);
|
||||
}
|
||||
map.values_mut().for_each(|kids| kids.sort_by_cached_key(|a| album_order_key(a)));
|
||||
map
|
||||
}
|
||||
/// Whether anything inside `parent` has a place of its own (the folder is ordered by hand).
|
||||
pub fn album_children_are_ordered(&self, parent: Option<AlbumId>) -> bool {
|
||||
self.albums.values().any(|a| a.parent == parent && a.order.is_some())
|
||||
}
|
||||
/// The Quick Collection, once something was added to it.
|
||||
pub fn quick_collection(&self) -> Option<AlbumId> {
|
||||
self.albums.values().find(|a| a.quick).map(|a| a.id)
|
||||
@@ -479,7 +506,20 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
let a = self.album_mut(id)?;
|
||||
Op::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) }
|
||||
if a.parent == parent {
|
||||
return Ok(Op::MoveAlbum { id, parent });
|
||||
}
|
||||
// its place belonged to the old neighbours: the undo gives it back with the folder
|
||||
let old_order = a.order.take();
|
||||
let old_parent = std::mem::replace(&mut a.parent, parent);
|
||||
match old_order {
|
||||
Some(_) => Op::Batch { ops: vec![Op::MoveAlbum { id, parent: old_parent }, Op::SetAlbumOrder { id, order: old_order }] },
|
||||
None => Op::MoveAlbum { id, parent: old_parent },
|
||||
}
|
||||
}
|
||||
Op::SetAlbumOrder { id, order } => {
|
||||
let a = self.album_mut(id)?;
|
||||
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos } => {
|
||||
let a = self.album_mut(id)?;
|
||||
@@ -668,9 +708,17 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// How siblings are listed: folders first, then the ones with a place of their own by it, then by
|
||||
/// name; the id settles any tie.
|
||||
fn album_order_key(a: &Album) -> (bool, bool, u32, String, AlbumId) {
|
||||
(!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
|
||||
@@ -295,6 +295,7 @@ impl Op {
|
||||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumOrder { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -336,12 +336,13 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw()
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
@@ -370,7 +371,12 @@ impl Photo {
|
||||
}
|
||||
/// Edited by the user: settings differ from what import gave the photo.
|
||||
pub fn is_edited(&self) -> bool {
|
||||
*self.develop != self.import_defaults() && !self.develop.is_unedited()
|
||||
let mut defaults = self.import_defaults();
|
||||
// As Shot reads the source's current WB; stored numbers can predate a decoder update.
|
||||
if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot {
|
||||
defaults.wb = self.develop.wb;
|
||||
}
|
||||
*self.develop != defaults && !self.develop.is_unedited()
|
||||
}
|
||||
/// Capture time if known, else import time (sort key).
|
||||
pub fn date(&self) -> &str {
|
||||
@@ -399,12 +405,16 @@ pub struct Album {
|
||||
/// The Quick Collection: a regular album that B adds to (one per library).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub quick: bool,
|
||||
/// Its place among the albums of the same kind in its folder, once the user ordered them by
|
||||
/// hand (see [`crate::Catalog::album_children`]); `None`: listed by name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// A regular (manual) album.
|
||||
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false, order: None }
|
||||
}
|
||||
pub fn is_smart(&self) -> bool {
|
||||
self.smart.is_some()
|
||||
@@ -430,7 +440,7 @@ mod edited_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_and_nikon_raws_use_relative_white_balance() {
|
||||
fn supported_raws_use_relative_white_balance() {
|
||||
for (name, format, relative) in [
|
||||
("a.arw", "ARW", true),
|
||||
("a.nef", "NEF", true),
|
||||
@@ -438,6 +448,8 @@ mod edited_tests {
|
||||
("a.rw2", "RW2", true),
|
||||
("a.rwl", "RWL", true),
|
||||
("a.raw", "RAW", true),
|
||||
("a.raf", "rAf", true),
|
||||
("a.cr3", "cr3", true),
|
||||
("a.dng", "DNG", false),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
|
||||
@@ -446,6 +458,12 @@ mod edited_tests {
|
||||
assert_eq!(p.relative_wb(), relative, "{format}");
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
|
||||
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
|
||||
let mut custom = (*p.develop).clone();
|
||||
custom.wb.mode = WbMode::Custom;
|
||||
p.develop = Arc::new(custom);
|
||||
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
|
||||
// shown from the embedded preview: a rendered image, not a relative-WB raw
|
||||
p.preview_only = Some("unsupported".into());
|
||||
assert!(!p.relative_wb());
|
||||
|
||||
@@ -525,6 +525,17 @@ pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
key_within(&folder_key(path), &folder_key(root))
|
||||
}
|
||||
|
||||
/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
|
||||
/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
|
||||
/// the names keep their case, which the key lowers on Windows.
|
||||
pub fn folder_rest(path: &str, root: &str) -> Option<Vec<String>> {
|
||||
if !key_within(&folder_key(path), &folder_key(root)) {
|
||||
return None;
|
||||
}
|
||||
let depth = split_path(root, false).parts.len();
|
||||
Some(split_path(path, false).parts.into_iter().skip(depth).collect())
|
||||
}
|
||||
|
||||
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
|
||||
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
|
||||
/// separators) is its own identity, so it is read without building a key for it: the check runs
|
||||
|
||||
@@ -58,12 +58,22 @@ fn albums_and_folders() {
|
||||
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
||||
let f = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false, order: None },
|
||||
})
|
||||
.unwrap();
|
||||
let al = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
|
||||
album: Album {
|
||||
id: al,
|
||||
name: "Alps".into(),
|
||||
parent: Some(f),
|
||||
folder: false,
|
||||
photos: vec![a],
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(c.albums_of(a), vec![al]);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Album order: the children of a folder (or of the top level) are listed folders first, then
|
||||
//! alphabetically, until some of them are given a place of their own (`Album.order`); moving an
|
||||
//! album to another folder drops its place, and undoing the move brings it back.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, name: &str, parent: Option<AlbumId>, folder: bool) -> AlbumId {
|
||||
let id = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { parent, folder, ..Album::new(id, name) } }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn names(c: &Catalog, parent: Option<AlbumId>) -> Vec<String> {
|
||||
c.album_children(parent).iter().map(|a| a.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn place(c: &mut Catalog, id: AlbumId, order: Option<u32>) {
|
||||
c.apply(Op::SetAlbumOrder { id, order }).unwrap();
|
||||
}
|
||||
|
||||
/// Given albums that were never ordered, when they are listed, then folders come first and each
|
||||
/// group is alphabetical regardless of case.
|
||||
#[test]
|
||||
fn children_are_folders_first_then_alphabetical() {
|
||||
let mut c = Catalog::new();
|
||||
add(&mut c, "beta", None, false);
|
||||
add(&mut c, "Alpha", None, false);
|
||||
add(&mut c, "zoo", None, true);
|
||||
add(&mut c, "Archive", None, true);
|
||||
assert_eq!(names(&c, None), ["Archive", "zoo", "Alpha", "beta"]);
|
||||
assert!(!c.album_children_are_ordered(None));
|
||||
}
|
||||
|
||||
/// Given some children with a place of their own, then they follow it; the others come after, by
|
||||
/// name; folders stay ahead of albums.
|
||||
#[test]
|
||||
fn ordered_children_follow_their_order() {
|
||||
let mut c = Catalog::new();
|
||||
let a = add(&mut c, "A", None, false);
|
||||
let b = add(&mut c, "B", None, false);
|
||||
let cc = add(&mut c, "C", None, false);
|
||||
add(&mut c, "D", None, false);
|
||||
let f = add(&mut c, "F", None, true);
|
||||
place(&mut c, cc, Some(0));
|
||||
place(&mut c, a, Some(1));
|
||||
assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]);
|
||||
assert!(c.album_children_are_ordered(None));
|
||||
// equal places fall back to the name, so a damaged catalog still lists deterministically
|
||||
place(&mut c, b, Some(1));
|
||||
assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]);
|
||||
// another container is unaffected
|
||||
let inner = add(&mut c, "Z", Some(f), false);
|
||||
add(&mut c, "Y", Some(f), false);
|
||||
assert_eq!(names(&c, Some(f)), ["Y", "Z"]);
|
||||
assert!(!c.album_children_are_ordered(Some(f)));
|
||||
place(&mut c, inner, Some(0));
|
||||
assert_eq!(names(&c, Some(f)), ["Z", "Y"]);
|
||||
}
|
||||
|
||||
/// The one-pass map lists every folder's children exactly as `album_children` does.
|
||||
#[test]
|
||||
fn the_children_map_matches_the_children_of_each_folder() {
|
||||
let mut c = Catalog::new();
|
||||
let f = add(&mut c, "F", None, true);
|
||||
let g = add(&mut c, "G", Some(f), true);
|
||||
for (name, parent) in [("b", None), ("a", None), ("y", Some(f)), ("x", Some(f)), ("q", Some(g))] {
|
||||
add(&mut c, name, parent, false);
|
||||
}
|
||||
let x = c.albums().find(|a| a.name == "x").unwrap().id;
|
||||
place(&mut c, x, Some(7));
|
||||
let map = c.album_children_by_parent();
|
||||
for parent in [None, Some(f), Some(g)] {
|
||||
let from_map: Vec<AlbumId> = map.get(&parent).map(|v| v.iter().map(|a| a.id).collect()).unwrap_or_default();
|
||||
let direct: Vec<AlbumId> = c.album_children(parent).iter().map(|a| a.id).collect();
|
||||
assert_eq!(from_map, direct, "{parent:?}");
|
||||
}
|
||||
assert!(!map.contains_key(&Some(AlbumId(999))));
|
||||
}
|
||||
|
||||
/// A place is one undo step, and `None` puts the album back among the alphabetical ones.
|
||||
#[test]
|
||||
fn a_place_can_be_set_cleared_and_undone() {
|
||||
let mut c = Catalog::new();
|
||||
let a = add(&mut c, "A", None, false);
|
||||
add(&mut c, "B", None, false);
|
||||
let inv = c.apply(Op::SetAlbumOrder { id: a, order: Some(5) }).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, Some(5));
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, None);
|
||||
assert!(c.apply(Op::SetAlbumOrder { id: AlbumId(999), order: Some(1) }).is_err(), "no such album");
|
||||
}
|
||||
|
||||
/// Moving to another folder drops the album's place there (it belonged to the old neighbours),
|
||||
/// and undoing the move gives it back.
|
||||
#[test]
|
||||
fn moving_drops_the_place_and_undo_restores_it() {
|
||||
let mut c = Catalog::new();
|
||||
let from = add(&mut c, "From", None, true);
|
||||
let to = add(&mut c, "To", None, true);
|
||||
let a = add(&mut c, "A", Some(from), false);
|
||||
place(&mut c, a, Some(3));
|
||||
let inv = c.apply(Op::MoveAlbum { id: a, parent: Some(to) }).unwrap();
|
||||
assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(to), None));
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(from), Some(3)));
|
||||
// "moving" to where it already is changes nothing
|
||||
c.apply(Op::MoveAlbum { id: a, parent: Some(from) }).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, Some(3));
|
||||
}
|
||||
|
||||
/// Catalogs written before albums had an order load as they were; an unset order is not written.
|
||||
#[test]
|
||||
fn albums_without_an_order_load_and_save_unchanged() {
|
||||
let old = r#"{"id":4,"name":"Old","parent":null,"folder":false,"photos":[],"cover":null}"#;
|
||||
let a: Album = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(a.order, None);
|
||||
assert!(!serde_json::to_string(&a).unwrap().contains("order"));
|
||||
let mut a = a;
|
||||
a.order = Some(2);
|
||||
let back: Album = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(back.order, Some(2));
|
||||
}
|
||||
@@ -47,6 +47,19 @@ fn v1_library_loads_and_is_upgraded() {
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, None));
|
||||
}
|
||||
|
||||
/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3.
|
||||
#[test]
|
||||
fn v2_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":2,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(2)));
|
||||
assert_eq!(snapshot_version(&m), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
@@ -179,8 +192,9 @@ fn op_variants_are_versioned() {
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
Op::SetAlbumOrder { .. } => 3,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
|
||||
let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -59,6 +59,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -77,6 +78,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
|
||||
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
|
||||
9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) },
|
||||
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
|
||||
9 if b.is_multiple_of(2) => {
|
||||
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-heif = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -34,6 +35,10 @@ ravif = { workspace = true, optional = true }
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
|
||||
heif = ["dep:lightcraft-heif"]
|
||||
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
|
||||
avif = ["dep:ravif"]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
|
||||
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
|
||||
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
|
||||
|
||||
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! HEIF / HEIC (the iPhone and Mac photo format), read-only.
|
||||
//!
|
||||
//! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC
|
||||
//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC
|
||||
//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call
|
||||
//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still
|
||||
//! detected and opening one is an [`Error::Unsupported`] error, never a panic.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
|
||||
//! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only
|
||||
//! mirrors what the container says, and applying it as well would turn the photo twice.
|
||||
//! The samples come back in the container's own primaries with its ICC profile (if any); like
|
||||
//! every decoder here, the colour interpretation happens in [`crate::convert::finish`].
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
use crate::convert::{Buf, Meta, Model, Raw, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
|
||||
|
||||
/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
|
||||
/// only; the wrapper names the exact construct in its own errors).
|
||||
#[cfg(feature = "heif")]
|
||||
const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)";
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result<Decoded> {
|
||||
Err(Error::Unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
fn err(e: lightcraft_heif::Error) -> Error {
|
||||
match e {
|
||||
// This crate's Unsupported takes a fixed reason; the dynamic wording lives in the
|
||||
// lightcraft-heif error itself.
|
||||
lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED),
|
||||
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
let info = lightcraft_heif::probe(bytes).map_err(err)?;
|
||||
if (info.width as u64).saturating_mul(info.height as u64) > opts.max_pixels {
|
||||
return Err(Error::TooLarge(info.width as u64, info.height as u64));
|
||||
}
|
||||
let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true };
|
||||
let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?;
|
||||
// The decoder re-reads the container; trust its output's shape, not the probe's.
|
||||
let buf = if decoded.sixteen_bit {
|
||||
let mut v = Vec::with_capacity(decoded.data.len() / 2);
|
||||
for pair in decoded.data.as_chunks::<2>().0 {
|
||||
v.push(u16::from_ne_bytes(*pair));
|
||||
}
|
||||
Buf::U16(v)
|
||||
} else {
|
||||
Buf::U8(decoded.data)
|
||||
};
|
||||
let raw = Raw {
|
||||
width: decoded.width as usize,
|
||||
height: decoded.height as usize,
|
||||
model: Model::Rgb,
|
||||
alpha: decoded.has_alpha,
|
||||
premultiplied: false,
|
||||
buf,
|
||||
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
|
||||
};
|
||||
let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() };
|
||||
finish(F, raw, meta, (decoded.width, decoded.height), opts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Format, sniff};
|
||||
|
||||
/// The container header both builds recognise, whatever the feature says.
|
||||
const HEIC_HEADER: &[u8] = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
|
||||
#[test]
|
||||
fn heif_is_always_recognised() {
|
||||
assert_eq!(sniff(HEIC_HEADER), Some(Format::Heif));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_decode_follows_the_feature() {
|
||||
assert_eq!(Format::Heif.can_decode(), cfg!(feature = "heif"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
fn without_the_feature_heif_is_a_clear_unsupported_error() {
|
||||
let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}");
|
||||
}
|
||||
|
||||
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
|
||||
/// out of range (`boxes.rs:130`). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50,
|
||||
0x72, 0x6d, 0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70,
|
||||
0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_error() {
|
||||
let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn an_image_sequence_is_unsupported() {
|
||||
// An ftyp with no meta box: a video track, not a still.
|
||||
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}");
|
||||
}
|
||||
}
|
||||
@@ -108,13 +108,64 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
|
||||
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
|
||||
/// type or component count, or when its markers don't run to an end-of-image marker after the
|
||||
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
}
|
||||
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
|
||||
if !matches!(m.sof, 0xC0..=0xC3) {
|
||||
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
|
||||
}
|
||||
if !matches!(m.components, 1 | 3 | 4) {
|
||||
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
|
||||
}
|
||||
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
|
||||
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
|
||||
}
|
||||
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((m.width, m.height, orientation))
|
||||
}
|
||||
|
||||
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
|
||||
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
|
||||
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
|
||||
/// markers that end it.
|
||||
fn reaches_eoi(b: &[u8]) -> bool {
|
||||
let mut p = 2usize;
|
||||
loop {
|
||||
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
|
||||
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
|
||||
p = p.saturating_add(skip);
|
||||
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
|
||||
p = match marker {
|
||||
0xD9 => return true,
|
||||
0xFF => p.saturating_add(1),
|
||||
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
|
||||
_ => {
|
||||
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
|
||||
return false;
|
||||
};
|
||||
if len < 2 {
|
||||
return false;
|
||||
}
|
||||
p.saturating_add(2).saturating_add(len)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
}
|
||||
check_size(F, m.width as u64, m.height as u64, opts)?;
|
||||
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh));
|
||||
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
|
||||
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
|
||||
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
|
||||
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
|
||||
@@ -175,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
|
||||
}
|
||||
|
||||
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
|
||||
/// is most of [`header`]'s time).
|
||||
fn find_ff(b: &[u8]) -> Option<usize> {
|
||||
const ONES: u64 = 0x0101_0101_0101_0101;
|
||||
let (words, tail) = b.as_chunks::<8>();
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
// a zero byte in !w is an 0xFF byte in w
|
||||
let x = !u64::from_ne_bytes(*w);
|
||||
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
|
||||
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
|
||||
}
|
||||
}
|
||||
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
|
||||
}
|
||||
|
||||
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
|
||||
/// fitted with the source aspect ratio.
|
||||
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
|
||||
@@ -297,6 +363,31 @@ mod tests {
|
||||
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ff_matches_a_plain_search() {
|
||||
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
|
||||
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
|
||||
if let Some(i) = at {
|
||||
b[i] = 0xFF;
|
||||
}
|
||||
for start in [0, 1, 5, 9, 290, 299, 300] {
|
||||
let s = &b[start..];
|
||||
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_fit_box_decodes_at_full_size() {
|
||||
// a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling,
|
||||
// and no overflow working out whether to scale
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
let pixels = vec![128u8; 32 * 24 * 3];
|
||||
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap();
|
||||
assert_eq!((d.width, d.height), (32, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_on_garbage() {
|
||||
assert!(parse_markers(b"").is_none());
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
|
||||
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
|
||||
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
|
||||
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
|
||||
//! decoding pixels (import probes).
|
||||
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
@@ -17,6 +19,7 @@
|
||||
mod convert;
|
||||
pub mod encode;
|
||||
pub mod exif;
|
||||
mod heif;
|
||||
pub mod icc;
|
||||
mod jpeg;
|
||||
pub mod jpeg_par;
|
||||
@@ -156,6 +159,52 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub format: Format,
|
||||
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
|
||||
pub orientation: u16,
|
||||
}
|
||||
|
||||
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
|
||||
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
|
||||
/// decode.
|
||||
///
|
||||
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
|
||||
/// unsupported layout, compression or frame type) and files whose image data is cut short
|
||||
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
|
||||
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
|
||||
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
|
||||
/// third-party parser is caught on unwinding targets).
|
||||
pub fn read_header(bytes: &[u8]) -> Result<Header> {
|
||||
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
|
||||
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
|
||||
}
|
||||
|
||||
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
|
||||
let (width, height, orientation) = match format {
|
||||
Format::Jpeg => jpeg::header(bytes)?,
|
||||
Format::Png => png_codec::header(bytes)?,
|
||||
Format::Tiff => tiff_codec::header(bytes)?,
|
||||
Format::WebP => webp::header(bytes)?,
|
||||
Format::Psd => psd::header(bytes)?,
|
||||
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
|
||||
_ => {
|
||||
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
|
||||
(d.source_width, d.source_height, d.orientation)
|
||||
}
|
||||
};
|
||||
Ok(Header { format, width, height, orientation })
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
@@ -179,8 +228,8 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
||||
Format::Jxl => jxl::decode(bytes, &opts),
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
|
||||
Format::Heif => heif::decode(bytes, &opts),
|
||||
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
|
||||
Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")),
|
||||
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
|
||||
/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
|
||||
/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
|
||||
/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
|
||||
pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
|
||||
if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
|
||||
return Err(err("missing IHDR"));
|
||||
}
|
||||
let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
|
||||
let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
|
||||
let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
|
||||
if w > i32::MAX as u32 || h > i32::MAX as u32 {
|
||||
return Err(err("dimensions out of range"));
|
||||
}
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let depth_ok = match color {
|
||||
0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
|
||||
3 => matches!(depth, 1 | 2 | 4 | 8),
|
||||
2 | 4 | 6 => matches!(depth, 8 | 16),
|
||||
_ => false,
|
||||
};
|
||||
if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
|
||||
return Err(err("invalid IHDR"));
|
||||
}
|
||||
let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
|
||||
while let Some(len) = be32(p) {
|
||||
let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
|
||||
if idat && kind != b"IDAT" {
|
||||
// a chunk follows the image data: the IDAT run is complete
|
||||
after_idat = true;
|
||||
}
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
let start = p.saturating_add(8);
|
||||
let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
|
||||
let Some(data) = data else {
|
||||
if after_idat {
|
||||
break; // a damaged chunk after the image data: a decode ignores it too
|
||||
}
|
||||
return Err(err("truncated"));
|
||||
};
|
||||
match kind {
|
||||
b"PLTE" => palette = true,
|
||||
b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
|
||||
b"IDAT" => {
|
||||
if color == 3 && !palette {
|
||||
return Err(err("indexed image without a palette"));
|
||||
}
|
||||
idat = true;
|
||||
}
|
||||
b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
|
||||
_ => {}
|
||||
}
|
||||
p = start.saturating_add(data.len()).saturating_add(4);
|
||||
}
|
||||
if !idat {
|
||||
return Err(err("no image data"));
|
||||
}
|
||||
if !after_idat {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
if let Some(c) = &info.coding_independent_code_points {
|
||||
let named = match c.color_primaries {
|
||||
|
||||
+265
-72
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (icc, exif, xmp) = resources(res);
|
||||
|
||||
// Layer and mask information: only the layer count sign matters (merged transparency).
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
let lm = r.take(lm_len)?;
|
||||
let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
|
||||
let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
|
||||
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
|
||||
let off = if psb { 8 } else { 4 };
|
||||
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
|
||||
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let plane = width * height * bpc;
|
||||
let row_bytes = width * bpc;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
|
||||
match compression {
|
||||
0 => {
|
||||
for _ in 0..channels {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let counts_len = channels * height;
|
||||
let mut counts = Vec::with_capacity(counts_len);
|
||||
for _ in 0..counts_len {
|
||||
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
for c in 0..channels {
|
||||
let mut out = Vec::with_capacity(plane);
|
||||
for y in 0..height {
|
||||
let src = r.take(counts[c * height + y])?;
|
||||
unpackbits(src, row_bytes, &mut out);
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
|
||||
let (model, n_color) = match mode {
|
||||
1 | 8 => (Model::Gray, 1),
|
||||
2 => (Model::Rgb, 1),
|
||||
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
let alpha = merged_transparency && channels > n_color && mode != 2;
|
||||
// Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
|
||||
// stored after them and never touched.
|
||||
let used = n_color + alpha as usize;
|
||||
let n = width * height;
|
||||
let too_large = || Error::TooLarge(width as u64, height as u64);
|
||||
let n = width.checked_mul(height).ok_or_else(too_large)?;
|
||||
let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(used);
|
||||
match compression {
|
||||
0 => {
|
||||
// Raw planes are borrowed from the input, so their size is bounded by the file.
|
||||
for _ in 0..used {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Row byte-count table: one entry per row per channel (all channels, since the data
|
||||
// follows it). Taken from the input before anything is allocated from it.
|
||||
let entry = if psb { 4 } else { 2 };
|
||||
let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
|
||||
let table = r.take(table_len)?;
|
||||
let count = |i: usize| -> usize {
|
||||
let at = i.saturating_mul(entry);
|
||||
match table.get(at..at.saturating_add(entry)) {
|
||||
Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
|
||||
Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
// PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
|
||||
// at least this many encoded bytes. Requiring it (and the data being present) before
|
||||
// allocating bounds the decoded planes by the file size: a tiny file can no longer
|
||||
// declare gigabytes of zero-padded or repeat-run rows.
|
||||
let min_row = row_bytes.div_ceil(128).saturating_mul(2);
|
||||
let mut data_len = 0usize;
|
||||
for i in 0..used.saturating_mul(height) {
|
||||
let c = count(i);
|
||||
if c < min_row {
|
||||
return Err(err("RLE row too short for its width"));
|
||||
}
|
||||
data_len = data_len.saturating_add(c);
|
||||
}
|
||||
if data_len > bytes.len().saturating_sub(r.p) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
for c in 0..used {
|
||||
let mut out = zeroed::<u8>(plane, too_large)?;
|
||||
for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
|
||||
let src = r.take(count(c * height + y))?;
|
||||
unpackbits(src, row)?;
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
let samples = n.checked_mul(used).ok_or_else(too_large)?;
|
||||
|
||||
// Interleave to device samples.
|
||||
let buf = if mode == 2 {
|
||||
// Indexed → 8-bit RGB via the 768-byte palette.
|
||||
if color_mode_data.len() < 768 || depth != 8 {
|
||||
let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let mut v = Vec::with_capacity(n * 3);
|
||||
for &i in &planes[0] {
|
||||
};
|
||||
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
|
||||
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
|
||||
for (o, &i) in v.as_chunks_mut::<3>().0.iter_mut().zip(idx) {
|
||||
let i = i as usize;
|
||||
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
|
||||
*o = [pal[i], pal[256 + i], pal[512 + i]];
|
||||
}
|
||||
Buf::U8(v)
|
||||
} else {
|
||||
let cmyk = model == Model::Cmyk;
|
||||
match bpc {
|
||||
1 => {
|
||||
let mut v = vec![0u8; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = p[i];
|
||||
let mut v = zeroed::<u8>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
|
||||
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
|
||||
*o = if cmyk && c < 4 { 255 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U8(v)
|
||||
}
|
||||
2 => {
|
||||
let mut v = vec![0u16; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
|
||||
let mut v = zeroed::<u16>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<2>().0) {
|
||||
let x = u16::from_be_bytes(*s);
|
||||
*o = if cmyk && c < 4 { 65535 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U16(v)
|
||||
}
|
||||
_ => {
|
||||
let mut v = vec![0f32; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
|
||||
let mut v = zeroed::<f32>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<4>().0) {
|
||||
let x = f32::from_be_bytes(*s);
|
||||
*o = if cmyk && c < 4 { 1.0 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::F32(v)
|
||||
@@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
|
||||
}
|
||||
|
||||
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
|
||||
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
|
||||
let target = out.len() + row_bytes;
|
||||
while out.len() < target && !src.is_empty() {
|
||||
let h = src[0] as i8;
|
||||
src = &src[1..];
|
||||
if h >= 0 {
|
||||
let n = (h as usize + 1).min(src.len()).min(target - out.len());
|
||||
out.extend_from_slice(&src[..n]);
|
||||
src = &src[(h as usize + 1).min(src.len())..];
|
||||
} else if h != -128 {
|
||||
let n = ((1 - h as isize) as usize).min(target - out.len());
|
||||
if let Some(&b) = src.first() {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
src = &src[1..];
|
||||
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
|
||||
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
|
||||
/// depth, colour mode or compression, or merged image data that runs past the end of the file
|
||||
/// (truncated). The run-length data itself is not unpacked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut r = Rd { b: bytes, p: 0 };
|
||||
if r.take(4)? != b"8BPS" {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let psb = match r.u16()? {
|
||||
1 => false,
|
||||
2 => true,
|
||||
_ => return Err(err("bad version")),
|
||||
};
|
||||
r.take(6)?;
|
||||
let channels = r.u16()? as usize;
|
||||
let height = r.u32()? as usize;
|
||||
let width = r.u32()? as usize;
|
||||
let depth = r.u16()?;
|
||||
let mode = r.u16()?;
|
||||
check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
|
||||
if channels == 0 || channels > 56 {
|
||||
return Err(err("bad channel count"));
|
||||
}
|
||||
let cm_len = r.u32()? as usize;
|
||||
let color_mode_data = r.take(cm_len)?;
|
||||
let res_len = r.u32()? as usize;
|
||||
let (_, exif, _) = resources(r.take(res_len)?);
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
r.take(lm_len)?;
|
||||
let compression = r.u16()?;
|
||||
let bpc = match depth {
|
||||
8 => 1usize,
|
||||
16 => 2,
|
||||
32 => 4,
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let n_color = match mode {
|
||||
1 | 8 | 7 | 2 => 1,
|
||||
3 => 3,
|
||||
4 => 4,
|
||||
9 => return Err(Error::Unsupported(F, "Lab PSD")),
|
||||
_ => return Err(err("unknown colour mode")),
|
||||
};
|
||||
if channels < n_color {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let rest = bytes.len().saturating_sub(r.p);
|
||||
let need = match compression {
|
||||
0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
|
||||
1 => {
|
||||
// the row byte counts, then the rows they count
|
||||
let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
|
||||
let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
|
||||
let mut total = table;
|
||||
for _ in 0..rows {
|
||||
total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
};
|
||||
if need.is_none_or(|n| n > rest) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((width as u32, height as u32, orientation))
|
||||
}
|
||||
|
||||
/// A zero-filled buffer whose allocation failure is an error, not an abort.
|
||||
fn zeroed<T: Clone + Default>(len: usize, too_large: impl Fn() -> Error) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| too_large())?;
|
||||
v.resize(len, T::default());
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
|
||||
/// A run that would write past the end of the row is an error; trailing bytes are ignored.
|
||||
fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
|
||||
let mut at = 0;
|
||||
while at < row.len() {
|
||||
let Some((&h, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
let h = h as i8;
|
||||
if h >= 0 {
|
||||
let n = h as usize + 1;
|
||||
let lit = src.get(..n).unwrap_or(src);
|
||||
let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
|
||||
dst.copy_from_slice(lit);
|
||||
at += lit.len();
|
||||
src = src.get(n..).unwrap_or(&[]);
|
||||
} else if h != -128 {
|
||||
let n = (1 - h as isize) as usize;
|
||||
let Some((&b, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
|
||||
at += n;
|
||||
}
|
||||
}
|
||||
out.resize(target, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
|
||||
@@ -234,11 +349,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn packbits() {
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
|
||||
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0x05, 1], 4, &mut out);
|
||||
assert_eq!(out, vec![1, 0, 0, 0]);
|
||||
let mut out = [0u8; 6];
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
|
||||
assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
// Short run data is zero-padded.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x01, 1], &mut out).unwrap();
|
||||
assert_eq!(out, [1, 0, 0, 0]);
|
||||
// Truncated literal run: copies what is there.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x03, 7, 8], &mut out).unwrap();
|
||||
assert_eq!(out, [7, 8, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packbits_overrun_is_an_error() {
|
||||
// Repeat run of 128 into a 4-byte row.
|
||||
let mut out = [0u8; 4];
|
||||
assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
|
||||
// Literal run of 6 into a 4-byte row.
|
||||
assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
|
||||
}
|
||||
|
||||
/// PSD header + empty colour-mode/resources/layer sections + compression id.
|
||||
fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec<u8> {
|
||||
let mut b = b"8BPS".to_vec();
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&channels.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&depth.to_be_bytes());
|
||||
b.extend_from_slice(&mode.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
|
||||
b.extend_from_slice(&compression.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
|
||||
// 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
|
||||
let mut b = header(56, 1, 1 << 30, 32, 3, 1);
|
||||
b.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
|
||||
// 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
|
||||
let mut b = header(3, 1 << 30, 1, 32, 3, 1);
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
|
||||
// 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
|
||||
let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
|
||||
b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_repeat_run_past_row_width_is_an_error() {
|
||||
// 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
|
||||
let mut b = header(1, 4, 1, 8, 1, 1);
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0x81, 0x55]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_gray16_and_extra_channels_decode() {
|
||||
// 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
|
||||
// plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
|
||||
let mut b = header(2, 2, 2, 16, 1, 1);
|
||||
for _ in 0..4 {
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
|
||||
let d = decode(&b, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (2, 2));
|
||||
let p = d.image.get(1, 1);
|
||||
let q = d.image.get(0, 0);
|
||||
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ impl Format {
|
||||
pub fn can_decode(self) -> bool {
|
||||
match self {
|
||||
Format::Jxl => cfg!(feature = "jxl"),
|
||||
Format::Avif | Format::Heif | Format::RawTiffLike | Format::RawOther => false,
|
||||
Format::Heif => cfg!(feature = "heif"),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
@@ -31,23 +28,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
|
||||
let (model, n_color, n_samples, bits) = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if bits < 8 && planar && n_samples > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -111,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
fn limits(opts: &DecodeOptions) -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
|
||||
/// bits per sample).
|
||||
fn layout(ct: ColorType, compression: Option<u32>, planar: bool) -> Result<(Model, usize, usize, u8)> {
|
||||
let l = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if l.3 < 8 && planar && l.2 > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
|
||||
/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
|
||||
/// start past the end of the file (truncated). The compressed data itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
layout(ct, compression, planar)?;
|
||||
// what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
|
||||
if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
|
||||
return Err(Error::Unsupported(F, "compression method"));
|
||||
}
|
||||
let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder<std::io::Cursor<&[u8]>>| {
|
||||
Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
|
||||
};
|
||||
let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
|
||||
.or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
|
||||
.ok_or_else(|| err("no image data"))?;
|
||||
// Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
|
||||
// decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
|
||||
let len = bytes.len() as u64;
|
||||
if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
|
||||
return Err(err("truncated: image data past the end of the file"));
|
||||
}
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
|
||||
Ok((w, h, orientation))
|
||||
}
|
||||
|
||||
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
|
||||
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
|
||||
@@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
|
||||
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
|
||||
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
|
||||
/// itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut p = 12usize;
|
||||
loop {
|
||||
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
|
||||
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
||||
let end = p.saturating_add(8).saturating_add(size);
|
||||
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
|
||||
if end > bytes.len() {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
p = end.saturating_add(size & 1);
|
||||
}
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let exif = d.exif_metadata().ok().flatten();
|
||||
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
@@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) {
|
||||
if let Some(f) = sniff(bytes) {
|
||||
let _ = decode_unguarded(bytes, f, &small_opts());
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
let _ = read_header_unguarded(bytes, f);
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = read_header(bytes);
|
||||
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
|
||||
//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
|
||||
|
||||
use lightcraft_codecs::exif::minimal_exif;
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn pixels(w: usize, h: usize) -> Rgba8 {
|
||||
Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
|
||||
}
|
||||
|
||||
fn meta(exif: &Option<Vec<u8>>) -> EncodeMeta<'_> {
|
||||
EncodeMeta { exif: exif.as_deref(), xmp: Some("<x/>"), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
|
||||
fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut cur = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut cur).unwrap();
|
||||
let mut im = enc.new_image::<colortype::RGB8>(img.width as u32, img.height as u32).unwrap();
|
||||
im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
|
||||
im.write_data(&rgb).unwrap();
|
||||
cur.into_inner()
|
||||
}
|
||||
|
||||
/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
|
||||
fn psd(w: usize, h: usize, orientation: Option<u16>, rle: bool) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"8BPS");
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
b.extend_from_slice(&(w as u32).to_be_bytes());
|
||||
b.extend_from_slice(&8u16.to_be_bytes());
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||||
let mut res = Vec::new();
|
||||
if let Some(o) = orientation {
|
||||
let exif = minimal_exif(o);
|
||||
res.extend_from_slice(b"8BIM");
|
||||
res.extend_from_slice(&1058u16.to_be_bytes());
|
||||
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
|
||||
res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
|
||||
res.extend_from_slice(&exif);
|
||||
if exif.len() % 2 == 1 {
|
||||
res.push(0);
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(&res);
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
|
||||
b.extend_from_slice(&(rle as u16).to_be_bytes());
|
||||
let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
|
||||
if rle {
|
||||
// every row as literal runs of at most 128 bytes (PackBits' longest)
|
||||
let row_len = w + w.div_ceil(128);
|
||||
for _ in 0..3 * h {
|
||||
b.extend_from_slice(&(row_len as u16).to_be_bytes());
|
||||
}
|
||||
for c in 0..3 {
|
||||
let p: Vec<u8> = plane(c).collect();
|
||||
for row in p.chunks(w) {
|
||||
for run in row.chunks(128) {
|
||||
b.push(run.len() as u8 - 1);
|
||||
b.extend_from_slice(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for c in 0..3 {
|
||||
b.extend(plane(c));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
|
||||
fn fixtures(w: usize, h: usize) -> Vec<(String, Vec<u8>)> {
|
||||
let img = pixels(w, h);
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
|
||||
let u16s: Vec<u16> = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
|
||||
let mut out = Vec::new();
|
||||
for o in [None, Some(1), Some(6), Some(8)] {
|
||||
let exif = o.map(minimal_exif);
|
||||
let tag = format!("o{}", o.unwrap_or(0));
|
||||
out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
|
||||
out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
|
||||
out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("psd {tag}"), psd(w, h, o, false)));
|
||||
out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
|
||||
out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
|
||||
}
|
||||
out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
|
||||
out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
|
||||
let mut bmp = Vec::new();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
|
||||
out.push(("bmp".into(), bmp));
|
||||
out
|
||||
}
|
||||
|
||||
/// What the import probe learnt before it read headers: a small decode.
|
||||
fn decoded(bytes: &[u8]) -> Result<Header> {
|
||||
let d = decode(bytes, DecodeOptions::fit(64, 64))?;
|
||||
Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_matches_decode() {
|
||||
for (w, h) in [(160, 96), (96, 160), (7, 5)] {
|
||||
for (name, bytes) in fixtures(w, h) {
|
||||
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
|
||||
assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
|
||||
assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
|
||||
}
|
||||
}
|
||||
let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
|
||||
assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
|
||||
}
|
||||
|
||||
/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
|
||||
/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
|
||||
/// is checked for truncation, which the header readers detect.
|
||||
#[test]
|
||||
fn truncated_files_agree_with_decode() {
|
||||
// 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
let n = bytes.len();
|
||||
let mut cuts: Vec<usize> = (0..100).map(|i| n * i / 100).collect();
|
||||
cuts.extend(n.saturating_sub(64)..n);
|
||||
for cut in cuts {
|
||||
let part = &bytes[..cut];
|
||||
if let Ok(d) = decoded(part) {
|
||||
assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
|
||||
}
|
||||
}
|
||||
for cut in [n / 4, n / 2, n * 9 / 10] {
|
||||
assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_details() {
|
||||
let f = fixtures(160, 96);
|
||||
let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
|
||||
let jpeg = get("jpeg 420 o6");
|
||||
assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
|
||||
let mut trailing = jpeg.clone();
|
||||
trailing.extend_from_slice(&[0; 100]);
|
||||
assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
|
||||
let png = get("png rgba o6");
|
||||
assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
|
||||
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
|
||||
}
|
||||
|
||||
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
|
||||
#[test]
|
||||
fn png_exif_after_idat() {
|
||||
let img = pixels(9, 4);
|
||||
let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
|
||||
let exif = minimal_exif(6);
|
||||
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
|
||||
chunk.extend_from_slice(b"eXIf");
|
||||
chunk.extend_from_slice(&exif);
|
||||
chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
|
||||
let iend = plain.len() - 12;
|
||||
let mut b = plain[..iend].to_vec();
|
||||
b.extend_from_slice(&chunk);
|
||||
b.extend_from_slice(&plain[iend..]);
|
||||
assert_eq!(read_header(&b).unwrap().orientation, 6);
|
||||
}
|
||||
|
||||
fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.push(comps);
|
||||
for c in 0..comps {
|
||||
b.extend_from_slice(&[c + 1, 0x11, 0]);
|
||||
}
|
||||
b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
|
||||
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
|
||||
b.extend_from_slice(&[0xFF, 0xD9]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_chunk(b: &mut Vec<u8>, kind: &[u8; 4], len: u32, data: &[u8]) {
|
||||
b.extend_from_slice(&len.to_be_bytes());
|
||||
b.extend_from_slice(kind);
|
||||
b.extend_from_slice(data);
|
||||
b.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_are_errors() {
|
||||
// a well-formed synthetic JPEG header is read
|
||||
assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
|
||||
let jpeg_bad = [
|
||||
jpeg_segments(0xC0, 0, 21, 3), // zero width
|
||||
jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
|
||||
jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
|
||||
jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
|
||||
jpeg_segments(0xC5, 33, 21, 3), // hierarchical
|
||||
jpeg_segments(0xC0, 33, 21, 2), // two components
|
||||
jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
|
||||
vec![0xFF, 0xD8, 0xFF, 0xD9],
|
||||
];
|
||||
for (i, b) in jpeg_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "JPEG case {i}");
|
||||
}
|
||||
let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
|
||||
no_sos.truncate(2 + 2 + 17);
|
||||
no_sos.extend_from_slice(&[0xFF, 0xD9]);
|
||||
assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
|
||||
|
||||
let mut ok = png_ihdr(5, 3, 8, 2);
|
||||
png_chunk(&mut ok, b"IDAT", 1, &[0]);
|
||||
png_chunk(&mut ok, b"IEND", 0, &[]);
|
||||
assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
|
||||
let png_case = |ihdr: Vec<u8>, chunks: &[(&[u8; 4], u32, &[u8])]| {
|
||||
let mut b = ihdr;
|
||||
for (k, l, d) in chunks {
|
||||
png_chunk(&mut b, k, *l, d);
|
||||
}
|
||||
b
|
||||
};
|
||||
let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
|
||||
let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
|
||||
let png_bad = [
|
||||
png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
|
||||
png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
|
||||
png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
|
||||
png_ihdr(5, 3, 8, 2)[..20].to_vec(),
|
||||
b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in png_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PNG case {i}");
|
||||
}
|
||||
|
||||
let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
|
||||
let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
|
||||
let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
|
||||
// cut short after the IFD
|
||||
assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
|
||||
tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
|
||||
for (i, b) in tiff_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "TIFF case {i}");
|
||||
}
|
||||
// a strip offset pointing past the end of the file
|
||||
let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
|
||||
let n = t(&tiff, ifd) as usize;
|
||||
let mut far = tiff.clone();
|
||||
for e in 0..n {
|
||||
let p = ifd + 2 + e * 12;
|
||||
if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
|
||||
far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
assert_ne!(far, tiff, "the strip offset was found");
|
||||
assert!(read_header(&far).is_err(), "strip past the end");
|
||||
|
||||
let webp_bad = [
|
||||
b"RIFF\0\0\0\0WEBP".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in webp_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "WebP case {i}");
|
||||
}
|
||||
|
||||
let good = psd(4, 3, Some(6), true);
|
||||
assert_eq!(read_header(&good).unwrap().orientation, 6);
|
||||
let set = |pos: usize, v: &[u8]| {
|
||||
let mut b = good.clone();
|
||||
b[pos..pos + v.len()].copy_from_slice(v);
|
||||
b
|
||||
};
|
||||
let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
|
||||
let compression = 34 + res_len + 4;
|
||||
let mut psd_bad = vec![
|
||||
set(12, &0u16.to_be_bytes()), // no channels
|
||||
set(12, &57u16.to_be_bytes()), // too many
|
||||
set(12, &2u16.to_be_bytes()), // too few for RGB
|
||||
set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
|
||||
set(18, &0u32.to_be_bytes()), // zero width
|
||||
set(22, &1u16.to_be_bytes()), // 1-bit
|
||||
set(22, &7u16.to_be_bytes()), // bad depth
|
||||
set(24, &9u16.to_be_bytes()), // Lab
|
||||
set(24, &77u16.to_be_bytes()), // unknown mode
|
||||
set(24, &2u16.to_be_bytes()), // indexed without a palette
|
||||
set(compression, &2u16.to_be_bytes()), // ZIP
|
||||
set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
|
||||
set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
|
||||
];
|
||||
// a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
|
||||
let mut tall = good.clone();
|
||||
tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
|
||||
tall[18..22].copy_from_slice(&1u32.to_be_bytes());
|
||||
psd_bad.push(tall);
|
||||
psd_bad.push(good[..good.len() - 1].to_vec());
|
||||
for (i, b) in psd_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PSD case {i}");
|
||||
}
|
||||
|
||||
for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
|
||||
assert!(read_header(b).is_err(), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
|
||||
/// header agrees with it (deterministic seed).
|
||||
#[test]
|
||||
fn mutated_files_agree_with_decode() {
|
||||
let mut s: u64 = 0x1234_5678_9abc_def1;
|
||||
let mut rnd = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
s
|
||||
};
|
||||
let (mut decodable, mut disagreements) = (0, Vec::new());
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
for _ in 0..300 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..1 + rnd() % 3 {
|
||||
let i = rnd() as usize % b.len();
|
||||
b[i] = rnd() as u8;
|
||||
}
|
||||
if let Ok(d) = decoded(&b) {
|
||||
decodable += 1;
|
||||
let h = read_header(&b);
|
||||
if h.as_ref().ok() != Some(&d) {
|
||||
disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(decodable > 1000, "{decodable}");
|
||||
assert!(disagreements.is_empty(), "{disagreements:#?}");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "lightcraft-denoise-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
rawnind-model = []
|
||||
|
||||
[dependencies]
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,7 @@
|
||||
# lightcraft-denoise-core
|
||||
|
||||
L1 model metadata, bounded archive/cache formats and Bayer packing/tiling helpers.
|
||||
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`.
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Taking one file out of a `.zip` (a `.dtmodel` is one), the little of the format a model download needs: stored and
|
||||
//! deflated entries, found by name, checked against their CRC-32 and a size limit, written next to where they are
|
||||
//! going and moved into place. It is a stranger's file, so every length and offset in it is checked, a name is only
|
||||
//! ever compared (never used as a path), and zip64, encrypted and multi-disk archives are refused.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// How far from the end the end-of-central-directory record can start (a 22-byte record and a 64 KiB comment).
|
||||
const EOCD_SEARCH: u64 = 22 + 65535;
|
||||
/// Largest central directory we read.
|
||||
const MAX_DIRECTORY: u64 = 1 << 20;
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ArchiveError {
|
||||
#[error("the archive could not be read: {0}")]
|
||||
Io(String),
|
||||
#[error("not a usable archive: {0}")]
|
||||
Format(String),
|
||||
#[error("the archive has no file called `{0}`")]
|
||||
Missing(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArchiveError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ArchiveError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn format<T>(why: &str) -> Result<T, ArchiveError> {
|
||||
Err(ArchiveError::Format(why.into()))
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE) of `bytes`.
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut table = [0u32; 256];
|
||||
for (i, t) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*t = c;
|
||||
}
|
||||
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
method: u16,
|
||||
crc: u32,
|
||||
compressed: u64,
|
||||
size: u64,
|
||||
local_header: u64,
|
||||
}
|
||||
|
||||
/// Find the entry called `name` (exactly, or as the last part of a longer name: `folder/name`) in the central directory.
|
||||
fn find(f: &mut (impl Read + Seek), name: &str) -> Result<Entry, ArchiveError> {
|
||||
let len = f.seek(SeekFrom::End(0))?;
|
||||
let tail_len = len.min(EOCD_SEARCH);
|
||||
f.seek(SeekFrom::Start(len - tail_len))?;
|
||||
let mut tail = vec![0u8; tail_len as usize];
|
||||
f.read_exact(&mut tail)?;
|
||||
let eocd = (0..tail.len().saturating_sub(21)).rev().find(|&i| u32_at(&tail, i) == Some(0x0605_4b50));
|
||||
let Some(e) = eocd else { return format("this is not a zip file") };
|
||||
let (entries, size, offset) = (u16_at(&tail, e + 10), u32_at(&tail, e + 12), u32_at(&tail, e + 16));
|
||||
let (Some(entries), Some(size), Some(offset)) = (entries, size, offset) else { return format("the end of the archive is cut short") };
|
||||
if entries == 0xffff || size == u32::MAX || offset == u32::MAX {
|
||||
return format("zip64 archives are not supported");
|
||||
}
|
||||
if u64::from(size) > MAX_DIRECTORY || u64::from(offset) + u64::from(size) > len {
|
||||
return format("the list of files is not where it says");
|
||||
}
|
||||
f.seek(SeekFrom::Start(u64::from(offset)))?;
|
||||
let mut dir = vec![0u8; size as usize];
|
||||
f.read_exact(&mut dir)?;
|
||||
let mut at = 0usize;
|
||||
for _ in 0..entries {
|
||||
if u32_at(&dir, at) != Some(0x0201_4b50) {
|
||||
return format("the list of files is damaged");
|
||||
}
|
||||
let get = |o: usize| at.checked_add(o);
|
||||
let (flags, method, crc) = (u16_at(&dir, at + 8), u16_at(&dir, at + 10), u32_at(&dir, at + 16));
|
||||
let (compressed, uncompressed) = (u32_at(&dir, at + 20), u32_at(&dir, at + 24));
|
||||
let (n, x, c) = (u16_at(&dir, at + 28), u16_at(&dir, at + 30), u16_at(&dir, at + 32));
|
||||
let local = u32_at(&dir, at + 42);
|
||||
let (Some(flags), Some(method), Some(crc), Some(compressed), Some(uncompressed), Some(n), Some(x), Some(c), Some(local), Some(start)) =
|
||||
(flags, method, crc, compressed, uncompressed, n, x, c, local, get(46))
|
||||
else {
|
||||
return format("the list of files is cut short");
|
||||
};
|
||||
let Some(entry_name) = dir.get(start..start + usize::from(n)) else { return format("a file name is cut short") };
|
||||
let matches = entry_name == name.as_bytes() || entry_name.strip_suffix(name.as_bytes()).is_some_and(|p| p.ends_with(b"/"));
|
||||
if matches {
|
||||
if flags & 1 != 0 {
|
||||
return format("encrypted files are not supported");
|
||||
}
|
||||
if compressed == u32::MAX || uncompressed == u32::MAX {
|
||||
return format("zip64 files are not supported");
|
||||
}
|
||||
return Ok(Entry { method, crc, compressed: u64::from(compressed), size: u64::from(uncompressed), local_header: u64::from(local) });
|
||||
}
|
||||
at = start + usize::from(n) + usize::from(x) + usize::from(c);
|
||||
}
|
||||
Err(ArchiveError::Missing(name.into()))
|
||||
}
|
||||
|
||||
/// Take the file called `name` out of the archive at `archive` and write it to `dest` (through a temporary file next
|
||||
/// to it); `max_bytes` bounds its size. Returns the number of bytes written.
|
||||
pub fn extract(archive: &Path, name: &str, dest: &Path, max_bytes: u64) -> Result<u64, ArchiveError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(archive)?);
|
||||
let e = find(&mut f, name)?;
|
||||
if e.size > max_bytes || e.compressed > max_bytes {
|
||||
return format("the file in the archive is larger than allowed");
|
||||
}
|
||||
f.seek(SeekFrom::Start(e.local_header))?;
|
||||
let mut head = [0u8; 30];
|
||||
f.read_exact(&mut head)?;
|
||||
if u32_at(&head, 0) != Some(0x0403_4b50) {
|
||||
return format("the file's header is not where it says");
|
||||
}
|
||||
let skip = u64::from(u16_at(&head, 26).unwrap_or(0)) + u64::from(u16_at(&head, 28).unwrap_or(0));
|
||||
f.seek_relative(i64::try_from(skip).map_err(|_| ArchiveError::Format("a header is too long".into()))?)?;
|
||||
let len = usize::try_from(e.compressed)
|
||||
.ok()
|
||||
.filter(|&n| n <= 256 * 1024 * 1024)
|
||||
.ok_or_else(|| ArchiveError::Format("the compressed model is too large".into()))?;
|
||||
if e.size > 256 * 1024 * 1024 {
|
||||
return format("the extracted model is too large");
|
||||
}
|
||||
let mut packed = Vec::new();
|
||||
packed.try_reserve_exact(len).map_err(|_| ArchiveError::Format("not enough memory for the model archive".into()))?;
|
||||
packed.resize(len, 0u8);
|
||||
f.read_exact(&mut packed)?;
|
||||
let data = match e.method {
|
||||
0 => packed,
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(&packed, max_bytes.min(256 * 1024 * 1024) as usize)
|
||||
.map_err(|err| ArchiveError::Format(format!("{err:?}")))?,
|
||||
m => return format(&format!("compression method {m} is not supported")),
|
||||
};
|
||||
if data.len() as u64 != e.size || crc32(&data) != e.crc {
|
||||
return format("the file in the archive is damaged");
|
||||
}
|
||||
let tmp = dest.with_extension("part");
|
||||
let written = (|| -> std::io::Result<()> {
|
||||
let mut out = std::fs::File::create(&tmp)?;
|
||||
out.write_all(&data)?;
|
||||
out.flush()?;
|
||||
drop(out);
|
||||
std::fs::rename(&tmp, dest)
|
||||
})();
|
||||
if let Err(err) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(data.len() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A zip with the given (name, bytes, deflate) entries, written by hand.
|
||||
pub(crate) fn make_zip(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut dir = Vec::new();
|
||||
for (name, data, deflate) in files {
|
||||
let packed = if *deflate { miniz_oxide::deflate::compress_to_vec(data, 6) } else { data.to_vec() };
|
||||
let offset = out.len() as u32;
|
||||
let method: u16 = if *deflate { 8 } else { 0 };
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[20, 0, 0, 0]);
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&crc32(data).to_le_bytes());
|
||||
out.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&packed);
|
||||
dir.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
dir.extend_from_slice(&[20, 0, 20, 0, 0, 0]);
|
||||
dir.extend_from_slice(&method.to_le_bytes());
|
||||
dir.extend_from_slice(&[0, 0, 0, 0]);
|
||||
dir.extend_from_slice(&crc32(data).to_le_bytes());
|
||||
dir.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
dir.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
dir.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
dir.extend_from_slice(&[0; 12]);
|
||||
dir.extend_from_slice(&offset.to_le_bytes());
|
||||
dir.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd_offset = out.len() as u32;
|
||||
out.extend_from_slice(&dir);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(dir.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd_offset.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-zip-{name}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_and_a_deflated_file_come_out_whole() {
|
||||
let big: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let zip = make_zip(&[("m/config.json", b"{}", false), ("m/model.onnx", &big, true), ("m/other.onnx", b"zz", true)]);
|
||||
let (a, out) = (temp("a.zip"), temp("a.onnx"));
|
||||
std::fs::write(&a, &zip).unwrap();
|
||||
assert_eq!(extract(&a, "model.onnx", &out, 1 << 20).unwrap(), big.len() as u64);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), big);
|
||||
assert_eq!(extract(&a, "m/config.json", &out, 1 << 20).unwrap(), 2);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"{}");
|
||||
// a name must match whole path parts: `del.onnx` is not `model.onnx`
|
||||
assert!(matches!(extract(&a, "del.onnx", &out, 1 << 20), Err(ArchiveError::Missing(_))));
|
||||
assert!(matches!(extract(&a, "nothing", &out, 1 << 20), Err(ArchiveError::Missing(_))));
|
||||
// the size limit applies
|
||||
assert!(matches!(extract(&a, "model.onnx", &out, 1000), Err(ArchiveError::Format(_))));
|
||||
for p in [a, out] {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: `LC_DENOISE_DTMODEL=<rawdenoise-nind.dtmodel> cargo test -p lightcraft-denoise -- --ignored archive`
|
||||
#[test]
|
||||
#[ignore = "needs the real download: set LC_DENOISE_DTMODEL"]
|
||||
fn the_real_download_gives_the_model_it_should() {
|
||||
let Some(path) = std::env::var_os("LC_DENOISE_DTMODEL") else { return };
|
||||
let known = crate::known::find("rawnind-bayer").unwrap();
|
||||
let (d, entry) = (known.download.unwrap(), known.entry.unwrap());
|
||||
assert_eq!(crate::hash::sha256_file(Path::new(&path)).unwrap(), d.sha256);
|
||||
let out = temp("real.onnx");
|
||||
let n = extract(Path::new(&path), entry, &out, crate::manifest::MAX_MODEL_BYTES).unwrap();
|
||||
assert_eq!(Some(n), known.manifest.size_bytes);
|
||||
assert_eq!(Some(crate::hash::sha256_file(&out).unwrap()), known.manifest.sha256);
|
||||
let _ = std::fs::remove_file(out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_archives_are_errors_not_panics() {
|
||||
let zip = make_zip(&[("m/model.onnx", &vec![9u8; 5000], true)]);
|
||||
let (a, out) = (temp("b.zip"), temp("b.onnx"));
|
||||
// cut anywhere
|
||||
for n in (0..zip.len()).step_by(3) {
|
||||
std::fs::write(&a, &zip[..n]).unwrap();
|
||||
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err(), "cut at {n}");
|
||||
}
|
||||
// one byte changed anywhere: an error, or (when it falls in an unused field) the right file, never a panic
|
||||
for at in 0..zip.len() {
|
||||
let mut bad = zip.clone();
|
||||
bad[at] ^= 0x5a;
|
||||
std::fs::write(&a, &bad).unwrap();
|
||||
if extract(&a, "model.onnx", &out, 1 << 20).is_ok() {
|
||||
assert_eq!(std::fs::read(&out).unwrap(), vec![9u8; 5000], "a changed byte at {at} gave another file");
|
||||
}
|
||||
}
|
||||
// not a zip at all, and an empty file
|
||||
for junk in [Vec::new(), b"PK".to_vec(), vec![0u8; 100_000]] {
|
||||
std::fs::write(&a, junk).unwrap();
|
||||
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err());
|
||||
}
|
||||
assert!(extract(&temp("missing.zip"), "model.onnx", &out, 1 << 20).is_err());
|
||||
for p in [a, out] {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Bayer geometry: which way a mosaic is laid out, and how a patch of it is packed into the four planes
|
||||
//! (red, green on the red row, green on the blue row, blue) a denoise model takes.
|
||||
//!
|
||||
//! Every Bayer layout is the same 2 × 2 cell shifted by one pixel at most, so a layout is just where its
|
||||
//! first red sample is. The cell grid starts one pixel before the picture when that red sample is in
|
||||
//! column or row 1, and the picture is read with its edge mirrored (without repeating the edge sample, so
|
||||
//! the colour of a mirrored sample is the colour that belongs at that place).
|
||||
|
||||
/// Where the first red sample of a Bayer mosaic sits, `(dx, dy)` in `{0, 1}²`: RGGB is `(0, 0)`, GRBG `(1, 0)`,
|
||||
/// GBRG `(0, 1)`, BGGR `(1, 1)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Layout {
|
||||
pub dx: usize,
|
||||
pub dy: usize,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when
|
||||
/// it is not a Bayer cell (one red, one blue, the greens on the other diagonal).
|
||||
pub fn from_cell(cell: [u8; 4]) -> Option<Layout> {
|
||||
let at = |c: u8| cell.iter().position(|&v| v == c);
|
||||
let red = at(0)?;
|
||||
let blue = at(2)?;
|
||||
let greens = cell.iter().filter(|&&v| v == 1).count();
|
||||
// red and blue sit on one diagonal: indices 0+3 or 1+2
|
||||
if greens != 2 || red + blue != 3 {
|
||||
return None;
|
||||
}
|
||||
Some(Layout { dx: red % 2, dy: red / 2 })
|
||||
}
|
||||
|
||||
/// The cell grid's origin: `-1` along an axis whose first red sample is at 1, so that cell 0 starts on a red column or row.
|
||||
fn origin(self) -> (isize, isize) {
|
||||
(isize::try_from(self.dx).unwrap_or(isize::MAX).saturating_neg(), isize::try_from(self.dy).unwrap_or(isize::MAX).saturating_neg())
|
||||
}
|
||||
|
||||
/// Cells across and down covering a `width × height` mosaic.
|
||||
pub fn cells(self, width: usize, height: usize) -> (usize, usize) {
|
||||
let cells = |n: usize, phase: usize| n / 2 + (n % 2 + phase.min(1)).div_ceil(2);
|
||||
(cells(width, self.dx), cells(height, self.dy))
|
||||
}
|
||||
|
||||
/// The mosaic position of cell `(cx, cy)`'s top-left sample (may lie outside the picture).
|
||||
pub fn position(self, cx: isize, cy: isize) -> (isize, isize) {
|
||||
let (ox, oy) = self.origin();
|
||||
(ox.saturating_add(cx.saturating_mul(2)), oy.saturating_add(cy.saturating_mul(2)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `i` mirrored into `0..n` (ping-pong, edge not repeated), so `-1` is `1` and `n` is `n - 2`. `n == 0` gives 0.
|
||||
pub fn reflect(i: isize, n: usize) -> usize {
|
||||
if n <= 1 {
|
||||
return 0;
|
||||
}
|
||||
if let Some(period) = isize::try_from(n).ok().and_then(|n| (n - 1).checked_mul(2)) {
|
||||
let m = i.rem_euclid(period);
|
||||
return (if m < n as isize { m } else { period - m }) as usize;
|
||||
}
|
||||
let period = 2 * (n as i128 - 1);
|
||||
let m = (i as i128).rem_euclid(period);
|
||||
// The reflected value is in 0..n, so this conversion always fits usize.
|
||||
(if m < n as i128 { m } else { period - m }) as usize
|
||||
}
|
||||
|
||||
/// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]`
|
||||
/// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside
|
||||
/// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit.
|
||||
pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool {
|
||||
let Some(plane) = tile.checked_mul(tile) else { return false };
|
||||
let Some(values) = plane.checked_mul(4) else { return false };
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| tile == 0
|
||||
|| layout.dx > 1
|
||||
|| layout.dy > 1
|
||||
|| width.checked_mul(height) != Some(mosaic.len())
|
||||
|| out.len() != values
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(last) = isize::try_from(tile - 1) else { return false };
|
||||
let (ox, oy) = layout.origin();
|
||||
for (start, origin) in [(cx0, ox), (cy0, oy)] {
|
||||
if start.checked_mul(2).and_then(|v| v.checked_add(origin)).is_none()
|
||||
|| start.checked_add(last).and_then(|v| v.checked_mul(2)).and_then(|v| v.checked_add(origin)).and_then(|v| v.checked_add(1)).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// the four samples of a cell, as (dx, dy) from its top-left sample
|
||||
const SUB: [(isize, isize); 4] = [(0, 0), (1, 0), (0, 1), (1, 1)];
|
||||
for (p, &(sx, sy)) in SUB.iter().enumerate() {
|
||||
let Some(dst) = out.get_mut(p * plane..(p + 1) * plane) else { return false };
|
||||
for (j, row) in dst.chunks_exact_mut(tile).enumerate() {
|
||||
let (_, y) = layout.position(cx0, cy0 + j as isize);
|
||||
let ry = reflect(y + sy, height);
|
||||
let Some(src) = mosaic.get(ry * width..(ry + 1) * width) else { return false };
|
||||
for (i, v) in row.iter_mut().enumerate() {
|
||||
let (x, _) = layout.position(cx0 + i as isize, cy0);
|
||||
*v = src.get(reflect(x + sx, width)).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_positions_never_panic_or_write_output() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let mut out = [7.0; 4];
|
||||
for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] {
|
||||
assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out));
|
||||
assert_eq!(out, [7.0; 4]);
|
||||
}
|
||||
for n in [1, 2, isize::MAX as usize, usize::MAX] {
|
||||
for i in [isize::MIN, -1, 0, isize::MAX] {
|
||||
assert!(reflect(i, n) < n);
|
||||
}
|
||||
}
|
||||
assert_eq!(layout.cells(usize::MAX, usize::MAX), (usize::MAX / 2 + 1, usize::MAX / 2 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_from_cells() {
|
||||
assert_eq!(Layout::from_cell([0, 1, 1, 2]), Some(Layout { dx: 0, dy: 0 }));
|
||||
assert_eq!(Layout::from_cell([1, 0, 2, 1]), Some(Layout { dx: 1, dy: 0 }));
|
||||
assert_eq!(Layout::from_cell([1, 2, 0, 1]), Some(Layout { dx: 0, dy: 1 }));
|
||||
assert_eq!(Layout::from_cell([2, 1, 1, 0]), Some(Layout { dx: 1, dy: 1 }));
|
||||
// not Bayer: two reds, a missing blue, greens on the wrong diagonal
|
||||
assert_eq!(Layout::from_cell([0, 0, 1, 2]), None);
|
||||
assert_eq!(Layout::from_cell([0, 1, 2, 1]), None);
|
||||
assert_eq!(Layout::from_cell([1, 1, 1, 1]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_keeps_the_colour_of_a_site() {
|
||||
assert_eq!(reflect(-1, 10), 1);
|
||||
assert_eq!(reflect(-2, 10), 2);
|
||||
assert_eq!(reflect(10, 10), 8);
|
||||
assert_eq!(reflect(11, 10), 7);
|
||||
assert_eq!(reflect(25, 10), 7);
|
||||
assert_eq!(reflect(5, 0), 0);
|
||||
assert_eq!(reflect(5, 1), 0);
|
||||
for i in -40..60 {
|
||||
assert_eq!(reflect(i, 11) % 2, (i.rem_euclid(2)) as usize, "parity at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_puts_each_colour_in_its_plane_for_every_layout() {
|
||||
// a mosaic whose sample value says which colour it is: R = 10, G = 20, B = 30
|
||||
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
|
||||
let layout = Layout::from_cell(cell).unwrap();
|
||||
let (w, h) = (10usize, 8usize);
|
||||
let mosaic: Vec<f32> = (0..w * h).map(|i| [10.0, 20.0, 30.0][cell[(i / w % 2) * 2 + i % w % 2] as usize]).collect();
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let tile = nx.max(ny);
|
||||
let mut out = vec![0.0; 4 * tile * tile];
|
||||
assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out));
|
||||
let plane = tile * tile;
|
||||
for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() {
|
||||
assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g1_is_beside_the_red_sample_and_g2_under_it() {
|
||||
// RGGB with a distinct value at every site
|
||||
let (w, h) = (4, 4);
|
||||
let mosaic: Vec<f32> = (0..16).map(|i| i as f32).collect();
|
||||
let mut out = vec![0.0; 4 * 4];
|
||||
assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
// cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5
|
||||
assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]);
|
||||
// cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7
|
||||
assert_eq!([out[1], out[5], out[9], out[13]], [2.0, 3.0, 6.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_rejects_wrong_sizes() {
|
||||
let mut out = vec![0.0; 16];
|
||||
assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out));
|
||||
assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! SHA-256 of a model file, streamed (a model can be hundreds of megabytes).
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Lowercase hex SHA-256 of everything `r` yields.
|
||||
pub fn sha256_reader<R: Read>(mut r: R) -> io::Result<String> {
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = r.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(buf.get(..n).unwrap_or(&[]));
|
||||
}
|
||||
Ok(hex(&h.finalize()))
|
||||
}
|
||||
|
||||
/// Lowercase hex SHA-256 of a file.
|
||||
pub fn sha256_file(path: &Path) -> io::Result<String> {
|
||||
sha256_reader(io::BufReader::new(std::fs::File::open(path)?))
|
||||
}
|
||||
|
||||
/// Lowercase hex SHA-256 of `bytes`.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_digests() {
|
||||
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert_eq!(sha256_reader(&b"abc"[..]).unwrap(), sha256_hex(b"abc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The denoise models LightCraft knows about: what each one is, who may use it, and where to fetch it.
|
||||
//!
|
||||
//! None is part of LightCraft. A model with an address here can be downloaded when the user asks and has accepted
|
||||
//! its terms; anything else is brought by the user as an `.onnx` file with a `denoise-model.json` beside it.
|
||||
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
use crate::licence::{Commercial, Licence};
|
||||
|
||||
use crate::manifest::DenoiserManifest;
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
use crate::manifest::{Domain, Gain};
|
||||
|
||||
/// A known model, and how to get it.
|
||||
pub struct Known {
|
||||
pub manifest: DenoiserManifest,
|
||||
/// What to fetch (an archive or the `.onnx` itself); `None` when the user must bring the file.
|
||||
pub download: Option<Download>,
|
||||
/// The `.onnx` inside the downloaded archive (`None`: the download is the `.onnx`).
|
||||
pub entry: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// RawNIND's UtNet2 for Bayer sensors, as published for darktable (`rawdenoise-nind.dtmodel`, a zip with a Bayer and a
|
||||
/// linear model). Its weights are GPL-3.0; the training pictures are CC BY 4.0 and CC0.
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
fn rawnind_bayer() -> Known {
|
||||
let manifest = DenoiserManifest {
|
||||
id: "rawnind-bayer".into(),
|
||||
name: "RawNIND UtNet2 (Bayer)".into(),
|
||||
version: "1.0".into(),
|
||||
licence: Licence {
|
||||
name: "GPL-3.0".into(),
|
||||
commercial: Commercial::Yes,
|
||||
url: Some("https://www.gnu.org/licenses/gpl-3.0.html".into()),
|
||||
notice: "The weights are published under the GNU General Public License v3. LightCraft does not include them: they \
|
||||
are downloaded to your computer at your request and used by it only. If you pass the model file on, the \
|
||||
GPL applies to that copy. Commercial use is subject to the GPL terms, including applicable source, licence and notice obligations on redistribution."
|
||||
.into(),
|
||||
},
|
||||
source: Some("https://github.com/darktable-org/darktable-ai/tree/master/models/rawdenoise-nind".into()),
|
||||
sha256: Some("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901".into()),
|
||||
size_bytes: Some(31_056_425),
|
||||
provenance: "Trained on RawNIND, real photographs of the same scenes taken noisy and clean (images CC BY 4.0 and CC0, \
|
||||
published on Wikimedia Commons and by the University of Louvain). Paper: arXiv 2501.08924."
|
||||
.into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
};
|
||||
let download = Download {
|
||||
id: manifest.id.clone(),
|
||||
url: "https://github.com/darktable-org/darktable-ai/releases/download/release-5.6.0/rawdenoise-nind.dtmodel".into(),
|
||||
file_name: "rawdenoise-nind.dtmodel".into(),
|
||||
size_bytes: 57_700_134,
|
||||
sha256: "d71b5f1e727c85a359e6f74dca9e2016c9d8fc3e2f7ac3e9b347d80ceca969af".into(),
|
||||
};
|
||||
Known { manifest, download: Some(download), entry: Some("model_bayer.onnx") }
|
||||
}
|
||||
|
||||
/// Every model LightCraft knows.
|
||||
pub fn all() -> Vec<Known> {
|
||||
#[cfg(feature = "rawnind-model")]
|
||||
{
|
||||
vec![rawnind_bayer()]
|
||||
}
|
||||
#[cfg(not(feature = "rawnind-model"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The known model with this id.
|
||||
pub fn find(id: &str) -> Option<Known> {
|
||||
all().into_iter().find(|k| k.manifest.id == id)
|
||||
}
|
||||
|
||||
/// The known model whose `.onnx` has this SHA-256.
|
||||
pub fn by_sha256(sha256: &str) -> Option<DenoiserManifest> {
|
||||
all().into_iter().map(|k| k.manifest).find(|m| m.sha256.as_deref() == Some(sha256))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::validate;
|
||||
|
||||
#[test]
|
||||
fn known_models_are_valid_unique_and_downloaded_over_https() {
|
||||
let all = all();
|
||||
assert_eq!(all.is_empty(), !cfg!(feature = "rawnind-model"));
|
||||
validate(&rawnind_bayer().manifest).unwrap();
|
||||
for (i, k) in all.iter().enumerate() {
|
||||
validate(&k.manifest).unwrap();
|
||||
assert!(all.iter().skip(i + 1).all(|o| o.manifest.id != k.manifest.id), "unique ids");
|
||||
assert!(!k.manifest.licence.name.is_empty() && !k.manifest.licence.notice.is_empty(), "terms are shown before use");
|
||||
if let Some(d) = &k.download {
|
||||
assert_eq!(d.id, k.manifest.id);
|
||||
assert!(d.url.starts_with("https://") && d.sha256.len() == 64 && d.size_bytes > 0);
|
||||
// a name that cannot leave the staging folder
|
||||
assert!(!d.file_name.contains(['/', '\\']) && !d.file_name.starts_with('.'));
|
||||
}
|
||||
// a model that may not be used commercially is never offered for download
|
||||
if k.manifest.licence.commercial == Commercial::No {
|
||||
assert!(k.download.is_none(), "{}", k.manifest.id);
|
||||
}
|
||||
}
|
||||
assert_eq!(find("rawnind-bayer").is_some(), cfg!(feature = "rawnind-model"));
|
||||
assert!(find("nothing").is_none());
|
||||
assert_eq!(
|
||||
by_sha256("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901").map(|m| m.id),
|
||||
if cfg!(feature = "rawnind-model") { Some("rawnind-bayer".into()) } else { None }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Download {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
/// The name the file is kept under while it waits for the user to accept its terms.
|
||||
pub file_name: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub fn host(url: &str) -> &str {
|
||||
url.strip_prefix("https://").and_then(|r| r.split('/').next()).unwrap_or("")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Model metadata, bounded archive/cache formats and Bayer tiling, independent of inference.
|
||||
//! Used by featureless engine and web builds; no neural-network or matrix backend is linked.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
pub mod archive;
|
||||
pub mod bayer;
|
||||
pub mod hash;
|
||||
pub mod known;
|
||||
pub mod licence;
|
||||
pub mod manifest;
|
||||
pub mod product;
|
||||
pub mod run;
|
||||
pub mod tiles;
|
||||
|
||||
pub use manifest::{DenoiserManifest, Domain, Gain, ManifestError};
|
||||
pub use run::{Control, Error, Params, TileRunner, denoise_bayer};
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Commercial {
|
||||
Yes,
|
||||
/// Research or personal use only. Never bundled or redistributed by LightCraft.
|
||||
No,
|
||||
/// The licence or the training data is unclear: the user is told so before installing.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Licence {
|
||||
/// SPDX id or a short name ("MIT", "Apache-2.0", "InsightFace non-commercial research").
|
||||
pub name: String,
|
||||
pub commercial: Commercial,
|
||||
pub url: Option<String>,
|
||||
/// Plain-language terms shown before the model is enabled.
|
||||
pub notice: String,
|
||||
}
|
||||
|
||||
impl Default for Licence {
|
||||
fn default() -> Self {
|
||||
Self { name: String::new(), commercial: Commercial::Unknown, url: None, notice: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
let mut bytes = id.bytes();
|
||||
let first_ok = bytes.next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
first_ok && id.len() <= 64 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! A denoise model's manifest: what the model takes and gives, how big its tiles are and who may use it, as
|
||||
//! plain data.
|
||||
//!
|
||||
//! It comes from a `denoise-model.json` next to the `.onnx` file or from the user's `catalog.json`. Both are
|
||||
//! untrusted: [`validate`] must pass before a manifest is used, and [`parse`] applies it after reading JSON of
|
||||
//! bounded size. The licence, id and address rules are the face models' (`crate::licence`).
|
||||
|
||||
use crate::licence::{Licence, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Largest `denoise-model.json` we read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
/// Largest model file we accept (bytes).
|
||||
pub const MAX_MODEL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// What the model takes and what it gives.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Domain {
|
||||
/// The sensor's Bayer mosaic (black subtracted, white at 1, not white balanced) packed as four planes
|
||||
/// `[R, G1, G2, B]` of its 2 × 2 cells, forced to RGGB; gives camera RGB at the mosaic's resolution, so
|
||||
/// the picture is denoised and demosaiced in one step and must not be demosaiced again.
|
||||
BayerToRgb,
|
||||
}
|
||||
|
||||
/// How the model's output scale is brought back to the input's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Gain {
|
||||
/// The output already has the input's scale.
|
||||
None,
|
||||
/// The output has a scale of its own, `nominal` times the input's (a million, for one model) and a little
|
||||
/// different from tile to tile: each tile is scaled so that its channel means equal the input's. A tile too
|
||||
/// dark to measure, or whose measured scale is further than `maxDeviation` (a fraction, 0.05 = 5 %) from
|
||||
/// `nominal`, is scaled as if it were within that limit.
|
||||
MatchMean { nominal: f32, max_deviation: f32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DenoiserManifest {
|
||||
/// Lowercase letters, digits, `.` `_` `-`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub licence: Licence,
|
||||
/// Where the weights come from (an `https://` page or file), for the user's information.
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Lowercase hex SHA-256 of the `.onnx` file, when known.
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size_bytes: Option<u64>,
|
||||
/// What the model was trained on, in plain words; says "undisclosed" when it is.
|
||||
#[serde(default)]
|
||||
pub provenance: String,
|
||||
pub domain: Domain,
|
||||
/// Side of the square the model runs on, in packed cells (the file's fixed input size).
|
||||
pub tile: u32,
|
||||
/// Cells neighbouring tiles share; the picture is blended across them.
|
||||
pub overlap: u32,
|
||||
pub gain: Gain,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("`{0}` is not valid: {1}")]
|
||||
Field(&'static str, String),
|
||||
#[error("not a denoise-model manifest: {0}")]
|
||||
Parse(String),
|
||||
#[error("a manifest may be at most {MAX_MANIFEST_BYTES} bytes")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn bad<T>(field: &'static str, why: impl Into<String>) -> Result<T, ManifestError> {
|
||||
Err(ManifestError::Field(field, why.into()))
|
||||
}
|
||||
|
||||
fn text(field: &'static str, s: &str, min: usize, max: usize) -> Result<(), ManifestError> {
|
||||
let n = s.chars().count();
|
||||
if n < min || n > max {
|
||||
return bad(field, format!("must be {min} to {max} characters"));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && c != '\n') {
|
||||
return bad(field, "must not contain control characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn url(field: &'static str, u: &Option<String>) -> Result<(), ManifestError> {
|
||||
match u {
|
||||
None => Ok(()),
|
||||
Some(u) if u.len() <= 500 && u.starts_with("https://") && !u.chars().any(|c| c.is_control() || c.is_whitespace()) => Ok(()),
|
||||
Some(_) => bad(field, "must be an https:// address of at most 500 characters"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a manifest from outside before trusting it.
|
||||
pub fn validate(m: &DenoiserManifest) -> Result<(), ManifestError> {
|
||||
if !valid_id(&m.id) {
|
||||
return bad("id", "1 to 64 lowercase letters, digits, `.`, `_` or `-`, starting with a letter or digit");
|
||||
}
|
||||
text("name", &m.name, 1, 80)?;
|
||||
text("version", &m.version, 1, 40)?;
|
||||
text("provenance", &m.provenance, 0, 1000)?;
|
||||
text("licence.name", &m.licence.name, 0, 80)?;
|
||||
text("licence.notice", &m.licence.notice, 0, 2000)?;
|
||||
url("licence.url", &m.licence.url)?;
|
||||
url("source", &m.source)?;
|
||||
if let Some(h) = &m.sha256
|
||||
&& !(h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
|
||||
{
|
||||
return bad("sha256", "must be 64 lowercase hex digits");
|
||||
}
|
||||
if let Some(n) = m.size_bytes
|
||||
&& (n == 0 || n > MAX_MODEL_BYTES)
|
||||
{
|
||||
return bad("sizeBytes", format!("must be between 1 and {MAX_MODEL_BYTES}"));
|
||||
}
|
||||
// a U-Net with four poolings wants a multiple of 16; nothing smaller than 64 or larger than 2048 is a tile
|
||||
if !(64..=2048).contains(&m.tile) || !m.tile.is_multiple_of(16) {
|
||||
return bad("tile", "must be a multiple of 16 from 64 to 2048");
|
||||
}
|
||||
// tiles must overlap by less than half, or one tile's blend ramps would run into its other neighbour's
|
||||
if !m.overlap.is_multiple_of(2) || m.overlap < 16 || m.overlap.saturating_mul(2) >= m.tile {
|
||||
return bad("overlap", "must be even, at least 16 and less than half the tile");
|
||||
}
|
||||
if let Gain::MatchMean { nominal, max_deviation } = m.gain {
|
||||
if !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal)) {
|
||||
return bad("gain.nominal", "must be between 0.000001 and 1000000000000");
|
||||
}
|
||||
if !(max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation)) {
|
||||
return bad("gain.maxDeviation", "must be between 0 and 0.5");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read and validate a `denoise-model.json`.
|
||||
pub fn parse(bytes: &[u8]) -> Result<DenoiserManifest, ManifestError> {
|
||||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||
return Err(ManifestError::TooLarge);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
|
||||
let m: DenoiserManifest = serde_json::from_slice(bytes).map_err(|e| ManifestError::Parse(e.to_string()))?;
|
||||
validate(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn sample() -> DenoiserManifest {
|
||||
DenoiserManifest {
|
||||
id: "nind-bayer".into(),
|
||||
name: "Bayer denoiser".into(),
|
||||
version: "1".into(),
|
||||
licence: Licence::default(),
|
||||
source: Some("https://example.org/model.onnx".into()),
|
||||
sha256: None,
|
||||
size_bytes: Some(31_000_000),
|
||||
provenance: "trained on real raw noise pairs".into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_manifest_round_trips() {
|
||||
let m = sample();
|
||||
let json = serde_json::to_vec(&m).unwrap();
|
||||
assert_eq!(parse(&json).unwrap(), m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_fields_are_named() {
|
||||
let cases: Vec<(&str, Box<dyn Fn(&mut DenoiserManifest)>)> = vec![
|
||||
("id", Box::new(|m| m.id = "../evil".into())),
|
||||
("id", Box::new(|m| m.id = String::new())),
|
||||
("name", Box::new(|m| m.name = "x\u{7}".into())),
|
||||
("source", Box::new(|m| m.source = Some("http://insecure".into()))),
|
||||
("sha256", Box::new(|m| m.sha256 = Some("XYZ".into()))),
|
||||
("sizeBytes", Box::new(|m| m.size_bytes = Some(0))),
|
||||
("tile", Box::new(|m| m.tile = 500)),
|
||||
("tile", Box::new(|m| m.tile = 4096)),
|
||||
("overlap", Box::new(|m| m.overlap = 300)),
|
||||
("overlap", Box::new(|m| m.overlap = 63)),
|
||||
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN })),
|
||||
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: 2.0 })),
|
||||
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: 0.0, max_deviation: 0.1 })),
|
||||
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 })),
|
||||
];
|
||||
for (field, edit) in cases {
|
||||
let mut m = sample();
|
||||
edit(&mut m);
|
||||
match validate(&m) {
|
||||
Err(ManifestError::Field(f, _)) => assert_eq!(f, field),
|
||||
other => panic!("{field}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_bounded_and_tolerant() {
|
||||
assert_eq!(parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]), Err(ManifestError::TooLarge));
|
||||
assert!(matches!(parse(b"{ nope"), Err(ManifestError::Parse(_))));
|
||||
assert!(matches!(parse(b""), Err(ManifestError::Parse(_))));
|
||||
let mut with_bom = vec![0xEF, 0xBB, 0xBF];
|
||||
with_bom.extend(serde_json::to_vec(&sample()).unwrap());
|
||||
assert!(parse(&with_bom).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
//! The denoised picture as cached data, and the blend the Amount slider controls.
|
||||
//!
|
||||
//! A product is camera RGB at the sensor's resolution (what the model gave, before the raw's own opcodes and
|
||||
//! crop), kept as half floats in strips of rows, each strip byte-shuffled and deflated with its own checksum.
|
||||
//! It is derived data: any problem reading it (missing, truncated, corrupt, made for another photo or model) is
|
||||
//! just a cache miss, never a panic, and the picture can be made again.
|
||||
//!
|
||||
//! File: `LCDN`, version, header length, header JSON (`width`, `height`, `strip`, `key`), then for each strip its
|
||||
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
|
||||
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"LCDN";
|
||||
const VERSION: u8 = 1;
|
||||
const MAX_HEADER: usize = 4096;
|
||||
const MAX_SIDE: usize = 1 << 17;
|
||||
/// Most pixels a product may have (the denoiser's own limit, [`crate::run::MAX_PIXELS`]).
|
||||
const MAX_PIXELS: usize = crate::run::MAX_PIXELS;
|
||||
/// Rows per strip: about a megapixel of three planes at 24 MP widths.
|
||||
const STRIP_ROWS: usize = 128;
|
||||
/// Bumped when the meaning of a product changes (included in keys by the caller).
|
||||
pub const ALGORITHM: u32 = 1;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProductError {
|
||||
#[error("the denoised picture could not be read or written: {0}")]
|
||||
Io(String),
|
||||
#[error("not a usable denoised picture: {0}")]
|
||||
Format(String),
|
||||
/// A product made from something else (another photo state, model or version).
|
||||
#[error("the denoised picture is out of date")]
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ProductError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ProductError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub strip: usize,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, ProductError> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| ProductError::Format("not enough memory for the denoised picture".into()))?;
|
||||
v.resize(n, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `f32` as IEEE half-float bits, rounded to nearest even; beyond the range it saturates, NaN becomes 0.
|
||||
pub fn to_f16(v: f32) -> u16 {
|
||||
if !v.is_finite() {
|
||||
return if v.is_nan() {
|
||||
0
|
||||
} else if v > 0.0 {
|
||||
0x7bff
|
||||
} else {
|
||||
0xfbff
|
||||
};
|
||||
}
|
||||
let b = v.to_bits();
|
||||
let sign = ((b >> 16) & 0x8000) as u16;
|
||||
let exp = ((b >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let man = b & 0x7f_ffff;
|
||||
if exp >= 31 {
|
||||
return sign | 0x7bff;
|
||||
}
|
||||
if exp <= 0 {
|
||||
if exp < -10 {
|
||||
return sign;
|
||||
}
|
||||
let man = man | 0x80_0000;
|
||||
let shift = (14 - exp) as u32;
|
||||
let (half, rem, halfway) = (man >> shift, man & ((1u32 << shift) - 1), 1u32 << (shift - 1));
|
||||
let mut h = half as u16;
|
||||
if rem > halfway || (rem == halfway && h & 1 == 1) {
|
||||
h += 1;
|
||||
}
|
||||
return sign | h;
|
||||
}
|
||||
let mut h = (((exp as u32) << 10) | (man >> 13)) as u16;
|
||||
let rem = man & 0x1fff;
|
||||
if rem > 0x1000 || (rem == 0x1000 && h & 1 == 1) {
|
||||
h += 1;
|
||||
}
|
||||
if h >= 0x7c00 {
|
||||
h = 0x7bff;
|
||||
}
|
||||
sign | h
|
||||
}
|
||||
|
||||
/// Half-float bits as `f32`.
|
||||
pub fn from_f16(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||
let exp = i32::from((h >> 10) & 0x1f);
|
||||
let man = f32::from(h & 0x3ff);
|
||||
match exp {
|
||||
0 => sign * man * 2f32.powi(-24),
|
||||
31 => sign * 65504.0,
|
||||
_ => sign * (1.0 + man / 1024.0) * 2f32.powi(exp - 15),
|
||||
}
|
||||
}
|
||||
|
||||
fn crc(bytes: &[u8]) -> u32 {
|
||||
// CRC-32 (IEEE), bitwise: strips are small and this runs once per strip
|
||||
let mut table = [0u32; 256];
|
||||
for (i, t) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*t = c;
|
||||
}
|
||||
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
|
||||
}
|
||||
|
||||
/// One strip as bytes: for each plane (R, G, B) the low bytes of its half floats, then the high bytes.
|
||||
fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Result<Vec<u8>, ProductError> {
|
||||
let n = (y1 - y0) * img.width;
|
||||
let mut raw = allocate(6 * n, 0u8)?;
|
||||
let rows = img.data.get(y0 * img.width..y1 * img.width).unwrap_or(&[]);
|
||||
for (i, px) in rows.iter().enumerate() {
|
||||
for (c, &v) in px.iter().enumerate() {
|
||||
let h = to_f16(v);
|
||||
if let Some(lo) = raw.get_mut(2 * c * n + i) {
|
||||
*lo = h as u8;
|
||||
}
|
||||
if let Some(hi) = raw.get_mut((2 * c + 1) * n + i) {
|
||||
*hi = (h >> 8) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(miniz_oxide::deflate::compress_to_vec(&raw, 3))
|
||||
}
|
||||
|
||||
/// Write `img` to `path` (through a temporary file next to it, so a reader never sees half a product).
|
||||
pub fn write(path: &Path, img: &Rgb32f, key: &str) -> Result<(), ProductError> {
|
||||
let pixels = img.width.checked_mul(img.height).filter(|&n| n <= MAX_PIXELS);
|
||||
if img.width == 0 || img.height == 0 || img.width > MAX_SIDE || img.height > MAX_SIDE || pixels != Some(img.data.len()) {
|
||||
return Err(ProductError::Format("the picture has no usable size".into()));
|
||||
}
|
||||
let strips: Vec<(usize, usize)> = (0..img.height).step_by(STRIP_ROWS).map(|y| (y, (y + STRIP_ROWS).min(img.height))).collect();
|
||||
let packed: Vec<Vec<u8>> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect::<Result<_, _>>()?;
|
||||
let header = serde_json::to_vec(&Header { width: img.width, height: img.height, strip: STRIP_ROWS, key: key.to_string() })
|
||||
.map_err(|e| ProductError::Format(e.to_string()))?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let result = (|| -> Result<(), ProductError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
|
||||
f.write_all(MAGIC)?;
|
||||
f.write_all(&[VERSION, 0, 0, 0])?;
|
||||
f.write_all(&(header.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&header)?;
|
||||
for p in &packed {
|
||||
f.write_all(&(p.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&crc(p).to_le_bytes())?;
|
||||
}
|
||||
for p in &packed {
|
||||
f.write_all(p)?;
|
||||
}
|
||||
f.flush()?;
|
||||
drop(f);
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The parsed start of a product file: header, each strip's (compressed length, CRC-32), and the file length the
|
||||
/// start says the whole file has.
|
||||
struct Start {
|
||||
header: Header,
|
||||
table: Vec<(usize, u32)>,
|
||||
expected_len: u64,
|
||||
}
|
||||
|
||||
fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
|
||||
let mut fixed = [0u8; 12];
|
||||
r.read_exact(&mut fixed)?;
|
||||
if fixed.get(0..4) != Some(&MAGIC[..]) || fixed.get(4) != Some(&VERSION) {
|
||||
return Err(ProductError::Format("not a denoised picture of this version".into()));
|
||||
}
|
||||
let len = fixed.get(8..12).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).unwrap_or(u32::MAX) as usize;
|
||||
if len > MAX_HEADER {
|
||||
return Err(ProductError::Format("the header is too large".into()));
|
||||
}
|
||||
let mut hb = allocate(len, 0u8)?;
|
||||
r.read_exact(&mut hb)?;
|
||||
let h: Header = serde_json::from_slice(&hb).map_err(|e| ProductError::Format(e.to_string()))?;
|
||||
let pixels = h.width.saturating_mul(h.height);
|
||||
if h.width == 0 || h.height == 0 || h.width > MAX_SIDE || h.height > MAX_SIDE || pixels > MAX_PIXELS || h.strip != STRIP_ROWS {
|
||||
return Err(ProductError::Format("the size is not usable".into()));
|
||||
}
|
||||
let n = h.height.div_ceil(h.strip);
|
||||
let mut table = Vec::with_capacity(n);
|
||||
let mut total = 12 + len as u64 + 8 * n as u64;
|
||||
for _ in 0..n {
|
||||
let mut e = [0u8; 8];
|
||||
r.read_exact(&mut e)?;
|
||||
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
|
||||
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
|
||||
// a strip never compresses to much more than its raw size
|
||||
if l > 12 * h.strip * h.width + 4096 {
|
||||
return Err(ProductError::Format("a strip is larger than it can be".into()));
|
||||
}
|
||||
total += l as u64;
|
||||
table.push((l, c));
|
||||
}
|
||||
Ok(Start { header: h, table, expected_len: total })
|
||||
}
|
||||
|
||||
/// The header of the product at `path`, when it is one.
|
||||
pub fn read_header_at(path: &Path) -> Result<Header, ProductError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
Ok(read_start(&mut f)?.header)
|
||||
}
|
||||
|
||||
/// Whether `path` holds a complete-looking product made from `key` (the header and the file's length are checked;
|
||||
/// strips are checked when read).
|
||||
pub fn is_current(path: &Path, key: &str) -> bool {
|
||||
let check = || -> Result<bool, ProductError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let s = read_start(&mut f)?;
|
||||
Ok(s.header.key == key && std::fs::metadata(path)?.len() == s.expected_len)
|
||||
};
|
||||
check().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A rectangle of a product, in the product's pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// Half floats as `f32`, for all 65536 bit patterns (a table is much faster than computing each).
|
||||
fn f16_table() -> &'static [f32] {
|
||||
static TABLE: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| (0..=u16::MAX).map(from_f16).collect())
|
||||
}
|
||||
|
||||
/// Strips decoded together (they are inflated in parallel, then folded in order).
|
||||
const GROUP: usize = 8;
|
||||
|
||||
/// One strip, checked and inflated: for each plane the low bytes of its half floats, then the high bytes.
|
||||
fn decode_strip(index: usize, packed: &[u8], expect_crc: u32, n: usize) -> Result<Vec<u8>, ProductError> {
|
||||
if crc(packed) != expect_crc {
|
||||
return Err(ProductError::Format(format!("strip {index} is damaged")));
|
||||
}
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(packed, 6 * n).map_err(|e| ProductError::Format(format!("strip {index}: {e:?}")))?;
|
||||
if raw.len() != 6 * n {
|
||||
return Err(ProductError::Format(format!("strip {index} has the wrong size")));
|
||||
}
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Read the product at `path`, made from `key`, with every `factor × factor` block of pixels averaged into one
|
||||
/// (`factor` 1 = full size). The result is camera RGB.
|
||||
///
|
||||
/// With a `window`, only that rectangle is read and the blocks start at its corner; only whole blocks are made, so
|
||||
/// the result is `window.width / factor × window.height / factor` (the same blocks a binned development of the same
|
||||
/// crop makes). Without one the whole product is read and blocks at the right and bottom edges may be smaller.
|
||||
///
|
||||
/// With `clip` (white is 1), a block in which any sample of a colour is at or above it takes that colour's
|
||||
/// maximum instead of the mean, so a clipped highlight stays clipped for highlight reconstruction.
|
||||
pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize, clip: Option<f32>) -> Result<Rgb32f, ProductError> {
|
||||
let factor = factor.clamp(1, 1 << 12);
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let Start { header: h, table, expected_len } = read_start(&mut f)?;
|
||||
if h.key != key {
|
||||
return Err(ProductError::Stale);
|
||||
}
|
||||
if std::fs::metadata(path)?.len() != expected_len {
|
||||
return Err(ProductError::Format("the file is not the length its header says".into()));
|
||||
}
|
||||
let w = window.unwrap_or(Window { x: 0, y: 0, width: h.width, height: h.height });
|
||||
let fits = |a: usize, b: usize, limit: usize| a.checked_add(b).is_some_and(|e| e <= limit);
|
||||
if w.width == 0 || w.height == 0 || !fits(w.x, w.width, h.width) || !fits(w.y, w.height, h.height) {
|
||||
return Err(ProductError::Format("the window does not lie inside the picture".into()));
|
||||
}
|
||||
// whole blocks for a window; partial ones at the edges when the whole picture is read
|
||||
let whole = window.is_none();
|
||||
let (ow, oh) = if whole { (w.width.div_ceil(factor), w.height.div_ceil(factor)) } else { (w.width / factor, w.height / factor) };
|
||||
if ow == 0 || oh == 0 {
|
||||
return Err(ProductError::Format("the window is smaller than one block".into()));
|
||||
}
|
||||
// the picture rows and columns the blocks cover
|
||||
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
|
||||
let lut = f16_table();
|
||||
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
|
||||
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
|
||||
lut.get(usize::from(lo) | usize::from(hi) << 8).copied().unwrap_or(0.0)
|
||||
};
|
||||
// which strips are needed, and where each starts in the file (after the header and the strip table)
|
||||
let wanted = |s: usize| s * h.strip < y_end && (s + 1) * h.strip > w.y;
|
||||
let (mut sum, mut peak) = (allocate(ow, [0f32; 3])?, allocate(ow, [f32::MIN; 3])?);
|
||||
let (mut rows_in, mut oy) = (0usize, 0usize);
|
||||
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
|
||||
// skip the strips before the first one wanted
|
||||
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
|
||||
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
|
||||
for group in strips.chunks(GROUP) {
|
||||
let mut packed = Vec::with_capacity(group.len());
|
||||
for &s in group {
|
||||
let Some(&(len, _)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
|
||||
let mut p = allocate(len, 0u8)?;
|
||||
f.read_exact(&mut p)?;
|
||||
packed.push(p);
|
||||
}
|
||||
let decoded: Vec<Result<Vec<u8>, ProductError>> = group
|
||||
.par_iter()
|
||||
.zip(packed.par_iter())
|
||||
.map(|(&s, p)| {
|
||||
let rows = h.height.saturating_sub(s * h.strip).min(h.strip);
|
||||
decode_strip(s, p, table.get(s).map_or(0, |e| e.1), rows * h.width)
|
||||
})
|
||||
.collect();
|
||||
for (&s, raw) in group.iter().zip(decoded) {
|
||||
let raw = raw?;
|
||||
let y0 = s * h.strip;
|
||||
let rows = h.height.saturating_sub(y0).min(h.strip);
|
||||
let n = rows * h.width;
|
||||
for r in 0..rows {
|
||||
let y = y0 + r;
|
||||
if y < w.y || y >= y_end {
|
||||
continue;
|
||||
}
|
||||
let base = r * h.width;
|
||||
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
|
||||
let x0 = w.x + ox * factor;
|
||||
let x1 = (x0 + factor).min(x_end);
|
||||
for x in x0..x1 {
|
||||
for c in 0..3 {
|
||||
let v = value(&raw, c, n, base + x);
|
||||
if let (Some(s_), Some(p_)) = (sm.get_mut(c), pk.get_mut(c)) {
|
||||
*s_ += v;
|
||||
*p_ = p_.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows_in += 1;
|
||||
if rows_in == factor || y + 1 == y_end {
|
||||
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
|
||||
// the block's size at the right and bottom edges may be smaller
|
||||
let bw = (x_end - (w.x + ox * factor)).min(factor);
|
||||
let k = 1.0 / (rows_in * bw).max(1) as f32;
|
||||
if let Some(o) = out.data.get_mut(oy * ow + ox) {
|
||||
for c in 0..3 {
|
||||
let (s_, p_) = (sm.get(c).copied().unwrap_or(0.0), pk.get(c).copied().unwrap_or(0.0));
|
||||
if let Some(slot) = o.get_mut(c) {
|
||||
*slot = if factor > 1 && clip.is_some_and(|t| p_ >= t) { p_ } else { s_ * k };
|
||||
}
|
||||
}
|
||||
}
|
||||
*sm = [0.0; 3];
|
||||
*pk = [f32::MIN; 3];
|
||||
}
|
||||
rows_in = 0;
|
||||
oy += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`read_window`] of the whole product: every `factor × factor` block averaged into one.
|
||||
pub fn read(path: &Path, key: &str, factor: usize) -> Result<Rgb32f, ProductError> {
|
||||
read_window(path, key, None, factor, None)
|
||||
}
|
||||
|
||||
/// `base + (denoised − base) · amount` for every pixel (`amount` 0 to 1), in place in `base`. The two pictures must
|
||||
/// be the same size; if they are not, `base` is left as it was and `false` is returned.
|
||||
pub fn blend(base: &mut Rgb32f, denoised: &Rgb32f, amount: f32) -> bool {
|
||||
if base.width != denoised.width || base.height != denoised.height {
|
||||
return false;
|
||||
}
|
||||
let a = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
base.data.par_iter_mut().zip(denoised.data.par_iter()).for_each(|(b, d)| {
|
||||
for c in 0..3 {
|
||||
b[c] += (d[c] - b[c]) * a;
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn oversized_product_header_is_rejected_before_allocating_pixels() {
|
||||
let p = temp("oversized-header");
|
||||
let header = serde_json::to_vec(&Header { width: 10001, height: 10000, strip: STRIP_ROWS, key: "k".into() }).unwrap();
|
||||
let mut bytes = Vec::from(MAGIC.as_slice());
|
||||
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
|
||||
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&header);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
assert!(read_header_at(&p).is_err());
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgb32f {
|
||||
Rgb32f {
|
||||
width: w,
|
||||
height: h,
|
||||
data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.001 + 0.5 * ((i % 7) as f32 / 7.0)]).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_floats_round_trip_closely() {
|
||||
for v in [0.0f32, 1.0, -1.0, 0.5, 0.333_333, 1e-4, 6.1e-5, 3e-6, 123.456, 65000.0, -0.002] {
|
||||
let back = from_f16(to_f16(v));
|
||||
assert!((back - v).abs() <= v.abs() * 0.001 + 1e-7, "{v} -> {back}");
|
||||
}
|
||||
assert_eq!(from_f16(to_f16(1e10)), 65504.0);
|
||||
assert_eq!(to_f16(f32::NAN), 0);
|
||||
assert_eq!(from_f16(to_f16(f32::NEG_INFINITY)), -65504.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_product_survives_a_round_trip_at_full_and_reduced_size() {
|
||||
let (w, h) = (301, 517);
|
||||
let img = picture(w, h);
|
||||
let p = temp("round");
|
||||
write(&p, &img, "k1").unwrap();
|
||||
assert!(is_current(&p, "k1") && !is_current(&p, "k2"));
|
||||
assert_eq!(read_header_at(&p).unwrap().width, w);
|
||||
let full = read(&p, "k1", 1).unwrap();
|
||||
assert_eq!((full.width, full.height), (w, h));
|
||||
for (a, b) in full.data.iter().zip(&img.data) {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 0.002, "{a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
// averaged into blocks of 4: the sizes round up, and the mean of the picture is kept
|
||||
let small = read(&p, "k1", 4).unwrap();
|
||||
assert_eq!((small.width, small.height), (w.div_ceil(4), h.div_ceil(4)));
|
||||
let mean = |i: &Rgb32f| i.data.iter().map(|p| f64::from(p[1])).sum::<f64>() / i.data.len() as f64;
|
||||
assert!((mean(&small) - mean(&img)).abs() < 0.01);
|
||||
// a factor that does not divide the strip height is the same average
|
||||
let odd = read(&p, "k1", 5).unwrap();
|
||||
assert_eq!((odd.width, odd.height), (w.div_ceil(5), h.div_ceil(5)));
|
||||
assert!((mean(&odd) - mean(&img)).abs() < 0.01);
|
||||
assert!(matches!(read(&p, "other", 1), Err(ProductError::Stale)));
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
/// What binning a window of `img` by `k` means, written the slow obvious way.
|
||||
fn blocks(img: &Rgb32f, win: Window, k: usize, clip: Option<f32>) -> Rgb32f {
|
||||
let (ow, oh) = (win.width / k, win.height / k);
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: vec![[0.0; 3]; ow * oh] };
|
||||
for by in 0..oh {
|
||||
for bx in 0..ow {
|
||||
for c in 0..3 {
|
||||
let vals: Vec<f32> = (0..k * k).map(|i| img.data[(win.y + by * k + i / k) * img.width + win.x + bx * k + i % k][c]).collect();
|
||||
let max = vals.iter().copied().fold(f32::MIN, f32::max);
|
||||
out.data[by * ow + bx][c] =
|
||||
if k > 1 && clip.is_some_and(|t| max >= t) { max } else { vals.iter().sum::<f32>() / vals.len() as f32 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_read_as_the_blocks_of_that_crop() {
|
||||
let (w, h) = (301, 517);
|
||||
let mut img = picture(w, h);
|
||||
// a clipped sample in the middle of a block
|
||||
img.data[(130 + 2) * w + 61 + 1] = [1.0, 0.4, 0.4];
|
||||
let p = temp("window");
|
||||
write(&p, &img, "k1").unwrap();
|
||||
let near = |a: &Rgb32f, b: &Rgb32f| {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
for (x, y) in a.data.iter().zip(&b.data) {
|
||||
for c in 0..3 {
|
||||
assert!((x[c] - y[c]).abs() < 0.003, "{x:?} vs {y:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
// windows at odd places, across strip boundaries (128 rows), with factors that do and do not divide them
|
||||
for (win, k) in [
|
||||
(Window { x: 3, y: 5, width: 200, height: 300 }, 1),
|
||||
(Window { x: 3, y: 5, width: 200, height: 300 }, 4),
|
||||
(Window { x: 61, y: 130, width: 121, height: 250 }, 5),
|
||||
(Window { x: 0, y: 0, width: w, height: h }, 2),
|
||||
(Window { x: 1, y: 380, width: 90, height: 137 }, 3),
|
||||
] {
|
||||
let got = read_window(&p, "k1", Some(win), k, None).unwrap();
|
||||
near(&got, &blocks(&img, win, k, None));
|
||||
}
|
||||
// with a clip level the block with the clipped sample keeps its highest value, the others stay means
|
||||
let win = Window { x: 61, y: 130, width: 40, height: 20 };
|
||||
let clipped = read_window(&p, "k1", Some(win), 4, Some(0.99)).unwrap();
|
||||
near(&clipped, &blocks(&img, win, 4, Some(0.99)));
|
||||
assert!(clipped.data[0][0] > 0.99, "the clipped block stays clipped: {:?}", clipped.data[0]);
|
||||
let plain = read_window(&p, "k1", Some(win), 4, None).unwrap();
|
||||
assert!(plain.data[0][0] < 0.99, "an average falls below the clip level");
|
||||
// windows that do not fit, or are smaller than a block, are errors
|
||||
for bad in [
|
||||
Window { x: 0, y: 0, width: 0, height: 5 },
|
||||
Window { x: 290, y: 0, width: 20, height: 5 },
|
||||
Window { x: 0, y: 510, width: 5, height: 10 },
|
||||
Window { x: usize::MAX, y: 0, width: 2, height: 2 },
|
||||
Window { x: 0, y: 0, width: 3, height: 3 },
|
||||
] {
|
||||
assert!(read_window(&p, "k1", Some(bad), 4, None).is_err(), "{bad:?}");
|
||||
}
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smooth_picture_compresses_well() {
|
||||
let img = Rgb32f {
|
||||
width: 512,
|
||||
height: 512,
|
||||
data: (0..512 * 512)
|
||||
.map(|i| {
|
||||
let v = 0.2 + 0.0001 * (i % 512) as f32;
|
||||
[v, v * 0.8, v * 0.6]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let p = temp("size");
|
||||
write(&p, &img, "k").unwrap();
|
||||
let bytes = std::fs::metadata(&p).unwrap().len() as usize;
|
||||
assert!(bytes < 512 * 512 * 6 / 2, "{bytes} bytes for a smooth picture");
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_errors_not_panics() {
|
||||
let img = picture(100, 300);
|
||||
let p = temp("damage");
|
||||
write(&p, &img, "k").unwrap();
|
||||
let good = std::fs::read(&p).unwrap();
|
||||
// truncated at every length
|
||||
for cut in (0..good.len()).step_by(37) {
|
||||
std::fs::write(&p, &good[..cut]).unwrap();
|
||||
assert!(read(&p, "k", 1).is_err(), "cut at {cut}");
|
||||
assert!(!is_current(&p, "k"));
|
||||
}
|
||||
// a flipped byte anywhere is caught by a checksum, the header parse, or the key
|
||||
for at in (0..good.len()).step_by(53) {
|
||||
let mut bad = good.clone();
|
||||
bad[at] ^= 0xff;
|
||||
std::fs::write(&p, &bad).unwrap();
|
||||
let _ = read(&p, "k", 1); // must not panic; most are errors
|
||||
}
|
||||
// a header that claims an absurd size
|
||||
let mut huge = b"LCDN\x01\0\0\0".to_vec();
|
||||
let hdr = br#"{"width":4000000000,"height":4000000000,"strip":128,"key":"k"}"#;
|
||||
huge.extend((hdr.len() as u32).to_le_bytes());
|
||||
huge.extend(hdr);
|
||||
std::fs::write(&p, &huge).unwrap();
|
||||
assert!(matches!(read(&p, "k", 1), Err(ProductError::Format(_))));
|
||||
std::fs::write(&p, b"").unwrap();
|
||||
assert!(read(&p, "k", 1).is_err());
|
||||
assert!(read(&temp("missing"), "k", 1).is_err());
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_rejects_unusable_pictures() {
|
||||
let p = temp("bad");
|
||||
assert!(write(&p, &Rgb32f { width: 0, height: 0, data: vec![] }, "k").is_err());
|
||||
assert!(write(&p, &Rgb32f { width: 3, height: 3, data: vec![[0.0; 3]; 8] }, "k").is_err());
|
||||
assert!(!p.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mixes_by_amount_and_rejects_other_sizes() {
|
||||
let mut base = Rgb32f { width: 2, height: 1, data: vec![[0.0; 3], [1.0; 3]] };
|
||||
let dn = Rgb32f { width: 2, height: 1, data: vec![[1.0; 3], [0.0; 3]] };
|
||||
assert!(blend(&mut base, &dn, 0.25));
|
||||
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
|
||||
assert!(blend(&mut base, &dn, f32::NAN));
|
||||
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
|
||||
assert!(!blend(&mut base, &Rgb32f { width: 1, height: 1, data: vec![[0.0; 3]] }, 1.0));
|
||||
assert!(blend(&mut base, &dn, 1.0));
|
||||
assert_eq!(base.data, dn.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_matches_the_standard_check_value() {
|
||||
assert_eq!(crc(b"123456789"), 0xcbf4_3926);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Denoising a whole Bayer mosaic with a tile model: cut it into overlapping tiles, run the model on each, bring
|
||||
//! each tile's scale back to the input's, keep clipped highlights as they were, and blend the tiles together.
|
||||
//!
|
||||
//! The model itself is a [`TileRunner`], so everything around it (packing, tiling, scale matching, highlight
|
||||
//! protection, blending, cancelling, progress) is tested without one.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::bayer::{Layout, pack_tile};
|
||||
use crate::manifest::Gain;
|
||||
use crate::tiles::{starts, weight};
|
||||
|
||||
/// Most pixels one picture may have (guards the allocations that follow from its size).
|
||||
pub const MAX_PIXELS: usize = 100_000_000;
|
||||
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
|
||||
const MIN_SIGNAL: f32 = 0.003;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Input(String),
|
||||
#[error("the model gave {0}")]
|
||||
Model(String),
|
||||
#[error("the model could not run: {0}")]
|
||||
Runtime(String),
|
||||
#[error("the model could not be loaded: {0}")]
|
||||
Load(String),
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, Error> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| Error::Input("not enough memory for denoise".into()))?;
|
||||
v.resize(n, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Something that runs the model on one tile.
|
||||
pub trait TileRunner: Sync {
|
||||
/// `input` is the tile as four planes `[R, G1, G2, B]` of `tile × tile` cells (NCHW, batch 1); the answer is
|
||||
/// camera RGB, three planes of `2·tile × 2·tile` pixels (CHW), in the model's own scale.
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error>;
|
||||
}
|
||||
|
||||
pub struct Params {
|
||||
/// Side of the model's square, in cells.
|
||||
pub tile: usize,
|
||||
/// Cells neighbouring tiles share.
|
||||
pub overlap: usize,
|
||||
pub gain: Gain,
|
||||
/// White is 1; samples at or above this (minus a little) are clipped and keep their value.
|
||||
pub clip: Option<f32>,
|
||||
/// Tiles run at once (each model call may itself be single-threaded).
|
||||
pub parallel: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Control<'a> {
|
||||
pub cancel: Option<&'a AtomicBool>,
|
||||
/// `(tiles done, tiles in all)`.
|
||||
pub progress: Option<&'a (dyn Fn(usize, usize) + Sync)>,
|
||||
}
|
||||
|
||||
/// The tile grid of a picture: `(starts across, starts down)`.
|
||||
pub fn grid(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> (Vec<usize>, Vec<usize>) {
|
||||
let (nx, ny) = layout.cells(width, height);
|
||||
(starts(nx, tile, overlap), starts(ny, tile, overlap))
|
||||
}
|
||||
|
||||
/// How many model calls [`denoise_bayer`] makes for a picture.
|
||||
pub fn tile_count(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> usize {
|
||||
let (sx, sy) = grid(width, height, layout, tile, overlap);
|
||||
sx.len().saturating_mul(sy.len())
|
||||
}
|
||||
|
||||
/// Denoise (and demosaic) the normalised mosaic `mosaic` of `width × height` samples, laid out as `layout`.
|
||||
/// Gives camera RGB at the mosaic's resolution.
|
||||
pub fn denoise_bayer(
|
||||
mosaic: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
layout: Layout,
|
||||
runner: &dyn TileRunner,
|
||||
p: &Params,
|
||||
ctl: &Control<'_>,
|
||||
) -> Result<Rgb32f, Error> {
|
||||
let pixels = width
|
||||
.checked_mul(height)
|
||||
.filter(|&n| n > 0 && n <= MAX_PIXELS)
|
||||
.ok_or_else(|| Error::Input("the picture has no size, or is too large".into()))?;
|
||||
if mosaic.len() != pixels {
|
||||
return Err(Error::Input("the mosaic does not have width × height samples".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()));
|
||||
}
|
||||
if let Gain::MatchMean { nominal, max_deviation } = p.gain
|
||||
&& !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal) && max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation))
|
||||
{
|
||||
return Err(Error::Input("the model gain is not finite or within its supported range".into()));
|
||||
}
|
||||
if p.clip.is_some_and(|v| !v.is_finite() || v <= 0.0) {
|
||||
return Err(Error::Input("the highlight clip must be finite and positive".into()));
|
||||
}
|
||||
let (nx, ny) = layout.cells(width, height);
|
||||
let (sx, sy) = (starts(nx, p.tile, p.overlap), starts(ny, p.tile, p.overlap));
|
||||
let (cols, rows) = (sx.len(), sy.len());
|
||||
if cols == 0 || rows == 0 {
|
||||
return Err(Error::Input("the picture needs too many tiles".into()));
|
||||
}
|
||||
let tiles: Vec<(usize, usize, bool, bool, bool, bool)> = sy
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(j, &y0)| sx.iter().enumerate().map(move |(i, &x0)| (x0, y0, i > 0, i + 1 < cols, j > 0, j + 1 < rows)))
|
||||
.collect();
|
||||
let total = tiles.len();
|
||||
|
||||
let mut acc = allocate(pixels, [0f32; 3])?;
|
||||
let mut wsum = allocate(nx * ny, 0f32)?;
|
||||
let group = p.parallel.clamp(1, 4);
|
||||
let mut done = 0;
|
||||
// where the time goes, printed under `LIGHTCRAFT_PROFILE`
|
||||
let (mut t_wait, mut t_total) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
|
||||
let began = web_time::Instant::now();
|
||||
|
||||
// One group of tiles at a time: pack, run the model, check and bring each to the input's scale, in parallel.
|
||||
// Blending (sequential: the tiles add into one picture, in tile order so the result does not depend on timing) of
|
||||
// the previous group runs while the next group is on the model, so neither the card nor the cores wait for it.
|
||||
type Tile = (usize, usize, bool, bool, bool, bool);
|
||||
let compute = |chunk: &[Tile]| -> Vec<Result<Vec<f32>, Error>> {
|
||||
chunk
|
||||
.par_iter()
|
||||
.map(|&(x0, y0, bx, ax, by, ay)| {
|
||||
let mut input = allocate(4 * p.tile * p.tile, 0f32)?;
|
||||
if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) {
|
||||
return Err(Error::Input("a tile could not be packed".into()));
|
||||
}
|
||||
let mut out = runner.run(&input)?;
|
||||
let side = 2 * p.tile;
|
||||
if out.len() != 3 * side * side {
|
||||
return Err(Error::Model(format!("{} numbers for a tile, not the {} expected", out.len(), 3 * side * side)));
|
||||
}
|
||||
if out.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Model("numbers that are not finite".into()));
|
||||
}
|
||||
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
|
||||
Ok(out)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut blend = |chunk: &[Tile], outs: Vec<Result<Vec<f32>, Error>>| -> Result<(), Error> {
|
||||
for (&(x0, y0, bx, ax, by, ay), r) in chunk.iter().zip(outs) {
|
||||
blend_tile(&r?, &mut acc, &mut wsum, (width, height), (nx, ny), layout, (x0, y0), p, (bx, ax, by, ay));
|
||||
done += 1;
|
||||
if let Some(f) = ctl.progress {
|
||||
f(done, total);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut previous: Option<(&[Tile], Vec<Result<Vec<f32>, Error>>)> = None;
|
||||
for chunk in tiles.chunks(group) {
|
||||
if ctl.cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let (outs, blended) = rayon::join(
|
||||
|| compute(chunk),
|
||||
|| match previous.take() {
|
||||
Some((c, o)) => {
|
||||
let started = web_time::Instant::now();
|
||||
let r = blend(c, o);
|
||||
(r, started.elapsed())
|
||||
}
|
||||
None => (Ok(()), std::time::Duration::ZERO),
|
||||
},
|
||||
);
|
||||
blended.0?;
|
||||
t_wait += blended.1;
|
||||
previous = Some((chunk, outs));
|
||||
}
|
||||
if let Some((c, o)) = previous.take() {
|
||||
let started = web_time::Instant::now();
|
||||
blend(c, o)?;
|
||||
t_wait += started.elapsed();
|
||||
}
|
||||
t_total += began.elapsed();
|
||||
let started = web_time::Instant::now();
|
||||
// normalise by the weights
|
||||
let (ox, oy) = (layout.position(0, 0).0, layout.position(0, 0).1);
|
||||
acc.par_chunks_mut(width).enumerate().for_each(|(y, row)| {
|
||||
let cy = ((y as isize - oy) / 2) as usize;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let cx = ((x as isize - ox) / 2) as usize;
|
||||
let w = wsum.get(cy * nx + cx).copied().unwrap_or(0.0);
|
||||
if w > 0.0 {
|
||||
*px = px.map(|v| v / w);
|
||||
}
|
||||
}
|
||||
});
|
||||
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",
|
||||
group,
|
||||
t_total.as_secs_f64() * 1e3,
|
||||
t_wait.as_secs_f64() * 1e3,
|
||||
started.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
Ok(Rgb32f { width, height, data: acc })
|
||||
}
|
||||
|
||||
/// Bring a tile's output to the input's scale and keep clipped highlights as they were (in place).
|
||||
fn finish_tile(input: &[f32], out: &mut [f32], p: &Params, edges: (bool, bool, bool, bool)) {
|
||||
let t = p.tile;
|
||||
let side = 2 * t;
|
||||
if let Gain::MatchMean { nominal, max_deviation } = p.gain {
|
||||
// the part of the tile whose scale is measured: away from the edges that blend into a neighbour
|
||||
let m = p.overlap / 2;
|
||||
let (i0, i1) = (if edges.0 { m } else { 0 }, t - if edges.1 { m } else { 0 });
|
||||
let (j0, j1) = (if edges.2 { m } else { 0 }, t - if edges.3 { m } else { 0 });
|
||||
let cells = ((i1 - i0) * (j1 - j0)) as f64;
|
||||
let plane_sum = |pl: usize| -> f64 {
|
||||
(j0..j1)
|
||||
.map(|j| input.get(pl * t * t + j * t + i0..pl * t * t + j * t + i1).map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>()))
|
||||
.sum()
|
||||
};
|
||||
let in_mean = [plane_sum(0) / cells, (plane_sum(1) + plane_sum(2)) / (2.0 * cells), plane_sum(3) / cells];
|
||||
for (c, &mean_in) in in_mean.iter().enumerate() {
|
||||
let sum_out: f64 = (2 * j0..2 * j1)
|
||||
.map(|y| {
|
||||
out.get(c * side * side + y * side + 2 * i0..c * side * side + y * side + 2 * i1)
|
||||
.map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>())
|
||||
})
|
||||
.sum();
|
||||
let mean_out = sum_out / (4.0 * cells);
|
||||
let measured = if mean_in as f32 >= MIN_SIGNAL && mean_out > 0.0 { (mean_out / mean_in) as f32 } else { nominal };
|
||||
let g = measured.clamp(nominal * (1.0 - max_deviation), nominal * (1.0 + max_deviation));
|
||||
if g.is_finite() && g > 0.0 {
|
||||
let inv = 1.0 / g;
|
||||
if let Some(plane) = out.get_mut(c * side * side..(c + 1) * side * side) {
|
||||
plane.iter_mut().for_each(|v| *v *= inv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(clip) = p.clip {
|
||||
protect_clipped(input, out, t, clip);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the mosaic is at (or within a cell of) white, the output becomes the plain cell values again, so a clipped
|
||||
/// area stays exactly as clipped as the camera recorded it (highlight recovery later depends on that).
|
||||
fn protect_clipped(input: &[f32], out: &mut [f32], t: usize, clip: f32) {
|
||||
let side = 2 * t;
|
||||
let plane = t * t;
|
||||
let (Some(r), Some(g1), Some(g2), Some(b)) =
|
||||
(input.get(0..plane), input.get(plane..2 * plane), input.get(2 * plane..3 * plane), input.get(3 * plane..4 * plane))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut m: Vec<f32> = (0..plane).map(|i| r[i].max(g1[i]).max(g2[i]).max(b[i])).collect();
|
||||
if !m.iter().any(|&v| v >= clip) {
|
||||
return;
|
||||
}
|
||||
// 3×3 maximum over cells (separable)
|
||||
let dilate = |src: &[f32], horizontal: bool| -> Vec<f32> {
|
||||
(0..plane)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % t, i / t);
|
||||
let mut best = src[i];
|
||||
for d in [-1isize, 1] {
|
||||
let (nx, ny) = if horizontal { (x as isize + d, y as isize) } else { (x as isize, y as isize + d) };
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < t && (ny as usize) < t {
|
||||
best = best.max(src[ny as usize * t + nx as usize]);
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
m = dilate(&dilate(&m, true), false);
|
||||
let lo = clip - 0.03;
|
||||
for j in 0..t {
|
||||
for i in 0..t {
|
||||
let k = j * t + i;
|
||||
let s = ((m[k] - lo) / (clip - lo)).clamp(0.0, 1.0);
|
||||
if s <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let s = s * s * (3.0 - 2.0 * s);
|
||||
let plain = [r[k], 0.5 * (g1[k] + g2[k]), b[k]];
|
||||
for (c, &pv) in plain.iter().enumerate() {
|
||||
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
if let Some(v) = out.get_mut(c * side * side + (2 * j + sy) * side + 2 * i + sx) {
|
||||
*v += (pv - *v) * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a finished tile to the picture with its blend weights.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_tile(
|
||||
out: &[f32],
|
||||
acc: &mut [[f32; 3]],
|
||||
wsum: &mut [f32],
|
||||
size: (usize, usize),
|
||||
cells: (usize, usize),
|
||||
layout: Layout,
|
||||
at: (usize, usize),
|
||||
p: &Params,
|
||||
edges: (bool, bool, bool, bool),
|
||||
) {
|
||||
let (width, height) = size;
|
||||
let (nx, ny) = cells;
|
||||
let (t, side) = (p.tile, 2 * p.tile);
|
||||
let fade = p.overlap;
|
||||
let wx: Vec<f32> = (0..t).map(|i| weight(i, t, fade, edges.0, edges.1)).collect();
|
||||
let wy: Vec<f32> = (0..t).map(|j| weight(j, t, fade, edges.2, edges.3)).collect();
|
||||
for j in 0..t {
|
||||
let cy = at.1 + j;
|
||||
for i in 0..t {
|
||||
let cx = at.0 + i;
|
||||
// cells past the picture's edge (a picture smaller than one tile is padded) belong to no pixel
|
||||
if cx >= nx || cy >= ny {
|
||||
continue;
|
||||
}
|
||||
let (Some(&a), Some(&b)) = (wx.get(i), wy.get(j)) else { continue };
|
||||
let w = a * b;
|
||||
if let Some(s) = wsum.get_mut(cy * nx + cx) {
|
||||
*s += w;
|
||||
}
|
||||
let (px0, py0) = layout.position(cx as isize, cy as isize);
|
||||
for (sy, sx) in [(0isize, 0isize), (0, 1), (1, 0), (1, 1)] {
|
||||
let (x, y) = (px0 + sx, py0 + sy);
|
||||
if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
|
||||
continue;
|
||||
}
|
||||
let o = (2 * j + sy as usize) * side + 2 * i + sx as usize;
|
||||
let (Some(&r), Some(&g), Some(&bl)) = (out.get(o), out.get(side * side + o), out.get(2 * side * side + o)) else { continue };
|
||||
if let Some(px) = acc.get_mut(y as usize * width + x as usize) {
|
||||
px[0] += w * r;
|
||||
px[1] += w * g;
|
||||
px[2] += w * bl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stand-in model: the cell's samples as an RGB picture at twice the resolution (a plain 2 × 2 "demosaic"),
|
||||
/// times `scale`, so the output has a scale of its own like the real model's.
|
||||
struct Mock {
|
||||
tile: usize,
|
||||
scale: f32,
|
||||
}
|
||||
impl TileRunner for Mock {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let (t, side) = (self.tile, 2 * self.tile);
|
||||
let plane = t * t;
|
||||
let mut out = vec![0.0; 3 * side * side];
|
||||
for j in 0..t {
|
||||
for i in 0..t {
|
||||
let k = j * t + i;
|
||||
let rgb = [input[k], 0.5 * (input[plane + k] + input[2 * plane + k]), input[3 * plane + k]];
|
||||
for (c, v) in rgb.iter().enumerate() {
|
||||
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
out[c * side * side + (2 * j + sy) * side + 2 * i + sx] = v * self.scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn params(tile: usize, overlap: usize, gain: Gain) -> Params {
|
||||
Params { tile, overlap, gain, clip: None, parallel: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let model = Mock { tile: 16, scale: 1.0 };
|
||||
for (tile, overlap, gain) in [
|
||||
(usize::MAX, 0, Gain::None),
|
||||
(16, usize::MAX, Gain::None),
|
||||
(16, 0, Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN }),
|
||||
(16, 0, Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 }),
|
||||
] {
|
||||
assert!(matches!(denoise_bayer(&[0.5], 1, 1, layout, &model, ¶ms(tile, overlap, gain), &Control::default()), Err(Error::Input(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A smooth picture whose colour at a sample is `base[colour] · (1 + shading)`.
|
||||
fn mosaic(w: usize, h: usize, cell: [u8; 4]) -> Vec<f32> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let c = cell[(y % 2) * 2 + x % 2] as usize;
|
||||
[0.5, 0.3, 0.2][c] * (0.6 + 0.4 * ((x as f32 / 23.0).sin() * (y as f32 / 31.0).cos()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reference(m: &[f32], w: usize, h: usize, layout: Layout) -> Rgb32f {
|
||||
// what the mock gives for the whole picture in one tile
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let t = nx.max(ny).div_ceil(16) * 16;
|
||||
denoise_bayer(m, w, h, layout, &Mock { tile: t, scale: 1.0 }, ¶ms(t, 16, Gain::None), &Control::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_blend_to_the_same_picture_as_one_tile_for_every_layout() {
|
||||
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
|
||||
let layout = Layout::from_cell(cell).unwrap();
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, cell);
|
||||
let want = reference(&m, w, h, layout);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &Control::default()).unwrap();
|
||||
assert_eq!((got.width, got.height), (w, h));
|
||||
for (a, b) in got.data.iter().zip(&want.data) {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-5, "layout {cell:?}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picture_does_not_depend_on_how_many_tiles_run_at_once_or_how_long_each_takes() {
|
||||
// tiles that finish in a different order every time: blending still adds them in tile order
|
||||
struct Jittery(Mock);
|
||||
impl TileRunner for Jittery {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let wait = (input.iter().take(64).sum::<f32>() * 1000.0) as u64 % 7;
|
||||
std::thread::sleep(std::time::Duration::from_millis(wait));
|
||||
self.0.run(input)
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 1 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [1, 0, 2, 1]);
|
||||
let run = |parallel: usize| {
|
||||
let mut p = params(32, 8, Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 });
|
||||
p.parallel = parallel;
|
||||
denoise_bayer(&m, w, h, layout, &Jittery(Mock { tile: 32, scale: 1.01e6 }), &p, &Control::default()).unwrap()
|
||||
};
|
||||
let one = run(1);
|
||||
for parallel in [2, 3, 8] {
|
||||
assert_eq!(run(parallel).data, one.data, "{parallel} at once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_tile_or_a_cancel_ends_the_picture_without_a_panic() {
|
||||
struct Fails(Mock, std::sync::atomic::AtomicUsize);
|
||||
impl TileRunner for Fails {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
if self.1.fetch_add(1, Ordering::Relaxed) == 4 {
|
||||
return Err(Error::Runtime("boom".into()));
|
||||
}
|
||||
self.0.run(input)
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let r = denoise_bayer(
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&Fails(Mock { tile: 32, scale: 1.0 }, Default::default()),
|
||||
¶ms(32, 8, Gain::None),
|
||||
&Control::default(),
|
||||
);
|
||||
assert!(matches!(r, Err(Error::Runtime(_))), "{r:?}");
|
||||
let stop = AtomicBool::new(true);
|
||||
let ctl = Control { cancel: Some(&stop), progress: None };
|
||||
let r = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &ctl);
|
||||
assert!(matches!(r, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_of_its_own_is_taken_out_tile_by_tile() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let want = reference(&m, w, h, layout);
|
||||
let gain = Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 };
|
||||
// the scale differs a little from nominal; each tile's own scale is measured and removed
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.02e6 }, ¶ms(32, 8, gain), &Control::default()).unwrap();
|
||||
let mean = |img: &Rgb32f, c: usize| img.data.iter().map(|p| f64::from(p[c])).sum::<f64>() / img.data.len() as f64;
|
||||
for c in 0..3 {
|
||||
let (a, b) = (mean(&got, c), mean(&want, c));
|
||||
assert!((a - b).abs() / b < 0.01, "channel {c}: {a} vs {b}");
|
||||
}
|
||||
// a scale far from nominal is clamped rather than trusted
|
||||
let wild = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 3.0e6 }, ¶ms(32, 8, gain), &Control::default()).unwrap();
|
||||
assert!(mean(&wild, 1) > 2.0 * mean(&want, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_smaller_than_a_tile_is_padded_by_mirroring() {
|
||||
let layout = Layout { dx: 1, dy: 1 };
|
||||
let (w, h) = (20usize, 14usize);
|
||||
let m = mosaic(w, h, [2, 1, 1, 0]);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 64, scale: 1.0 }, ¶ms(64, 16, Gain::None), &Control::default()).unwrap();
|
||||
assert_eq!((got.width, got.height), (w, h));
|
||||
assert!(got.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v > 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_highlights_stay_clipped() {
|
||||
// a model that darkens everything by 10 % would pull a clipped plateau below white
|
||||
struct Dim(Mock);
|
||||
impl TileRunner for Dim {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(self.0.run(input)?.into_iter().map(|v| v * 0.9).collect())
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if i % w > 40 && i % w < 70 { 1.0 } else { 0.4 }).collect();
|
||||
let mut p = params(32, 8, Gain::None);
|
||||
p.clip = Some(0.99);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Dim(Mock { tile: 32, scale: 1.0 }), &p, &Control::default()).unwrap();
|
||||
let mid = got.data[48 * w + 55];
|
||||
assert!(mid.iter().all(|&v| v > 0.995), "plateau {mid:?}");
|
||||
let dark = got.data[48 * w + 10];
|
||||
assert!(dark.iter().all(|&v| (v - 0.36).abs() < 0.01), "unclipped area still goes through the model: {dark:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_and_progress_work() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let cancel = AtomicBool::new(true);
|
||||
let r = denoise_bayer(
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&Mock { tile: 32, scale: 1.0 },
|
||||
¶ms(32, 8, Gain::None),
|
||||
&Control { cancel: Some(&cancel), progress: None },
|
||||
);
|
||||
assert!(matches!(r, Err(Error::Cancelled)));
|
||||
let seen = std::sync::Mutex::new(Vec::new());
|
||||
let f = |d: usize, n: usize| seen.lock().unwrap().push((d, n));
|
||||
denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &Control { cancel: None, progress: Some(&f) })
|
||||
.unwrap();
|
||||
let seen = seen.into_inner().unwrap();
|
||||
let total = tile_count(w, h, layout, 32, 8);
|
||||
assert!(total > 1);
|
||||
assert_eq!(seen.last(), Some(&(total, total)));
|
||||
assert!(seen.windows(2).all(|p| p[1].0 == p[0].0 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_are_errors() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let mock = Mock { tile: 32, scale: 1.0 };
|
||||
let p = params(32, 8, Gain::None);
|
||||
assert!(matches!(denoise_bayer(&[], 0, 0, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.0; 10], 4, 4, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.0; 16], 4, 4, layout, &mock, ¶ms(32, 16, Gain::None), &Control::default()), Err(Error::Input(_))));
|
||||
struct Bad(Vec<f32>);
|
||||
impl TileRunner for Bad {
|
||||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Cutting a picture into overlapping tiles and putting the results back together.
|
||||
//!
|
||||
//! A model runs on a fixed square, so a picture is covered by tiles spaced evenly along each axis, each sharing
|
||||
//! at least `overlap` cells with its neighbours. Each tile's influence fades towards its own edges (except at the
|
||||
//! picture's edge), and the result is the sum of tile × weight divided by the sum of weights, so any set of
|
||||
//! overlapping tiles blends consistently and a seam between two tiles of slightly different scale is a smooth ramp.
|
||||
|
||||
/// Where the tiles start along one axis of `n` cells: `[0]` when the axis fits one tile (the tile is then padded by
|
||||
/// mirroring), else evenly spaced starts, the first at 0 and the last at `n − tile`, each tile sharing at least
|
||||
/// `overlap` cells with the next.
|
||||
pub fn starts(n: usize, tile: usize, overlap: usize) -> Vec<usize> {
|
||||
if n <= tile || tile == 0 {
|
||||
return vec![0];
|
||||
}
|
||||
let step = tile.saturating_sub(overlap).max(1);
|
||||
// tiles needed so that start k·spacing with spacing ≤ step reaches n − tile
|
||||
let Some(k) = (n - tile).div_ceil(step).checked_add(1).filter(|&k| k <= 1_000_000) else { return Vec::new() };
|
||||
let last = n - tile;
|
||||
(0..k).map(|i| if k == 1 { 0 } else { ((i as u128 * last as u128 + (k - 1) as u128 / 2) / (k - 1) as u128) as usize }).collect()
|
||||
}
|
||||
|
||||
/// Weight of the cell at offset `p` (0-based, inside a tile of `tile` cells) along one axis: 1 in the middle, easing
|
||||
/// down to a sliver over `fade` cells at an edge that has a neighbour, 1 right up to an edge that is the picture's.
|
||||
pub fn weight(p: usize, tile: usize, fade: usize, has_before: bool, has_after: bool) -> f32 {
|
||||
let fade = fade.max(1) as f32;
|
||||
let ramp = |d: usize| {
|
||||
let t = ((d as f32 + 0.5) / fade).min(1.0);
|
||||
// smoothstep, never exactly 0 so that a cell is always covered
|
||||
(t * t * (3.0 - 2.0 * t)).max(1e-3)
|
||||
};
|
||||
let before = if has_before { ramp(p) } else { 1.0 };
|
||||
let after = if has_after { ramp(tile.saturating_sub(1).saturating_sub(p)) } else { 1.0 };
|
||||
before.min(after)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_axes_have_bounded_tile_counts_and_checked_spacing() {
|
||||
assert!(starts(usize::MAX, 1, 0).is_empty());
|
||||
assert!(starts(usize::MAX, 2, usize::MAX).is_empty());
|
||||
let s = starts(usize::MAX, usize::MAX / 2, 0);
|
||||
assert_eq!(s.first(), Some(&0));
|
||||
assert_eq!(s.last(), Some(&(usize::MAX - usize::MAX / 2)));
|
||||
assert!(s.windows(2).all(|s| s[0] < s[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tile_when_it_fits() {
|
||||
assert_eq!(starts(100, 512, 64), vec![0]);
|
||||
assert_eq!(starts(512, 512, 64), vec![0]);
|
||||
assert_eq!(starts(0, 512, 64), vec![0]);
|
||||
assert_eq!(starts(10, 0, 0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_cover_the_axis_and_overlap_enough() {
|
||||
for n in [513usize, 600, 900, 1000, 1024, 1500, 3024, 3600, 5000, 12345] {
|
||||
let s = starts(n, 512, 64);
|
||||
assert_eq!(s[0], 0, "n={n}");
|
||||
assert_eq!(*s.last().unwrap(), n - 512, "n={n}");
|
||||
for w in s.windows(2) {
|
||||
assert!(w[1] > w[0], "starts increase, n={n}");
|
||||
assert!(w[0] + 512 >= w[1] + 64, "at least 64 shared cells between {} and {}, n={n}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_fade_only_towards_neighbours() {
|
||||
let (t, f) = (512, 64);
|
||||
// a lone tile: full weight everywhere
|
||||
assert!((0..t).all(|p| weight(p, t, f, false, false) == 1.0));
|
||||
// the first tile fades on its far side only
|
||||
assert_eq!(weight(0, t, f, false, true), 1.0);
|
||||
assert!(weight(t - 1, t, f, false, true) < 0.01);
|
||||
// the last tile fades on its near side only
|
||||
assert!(weight(0, t, f, true, false) < 0.01);
|
||||
assert_eq!(weight(t - 1, t, f, true, false), 1.0);
|
||||
// a middle tile is 1 in the middle and everywhere positive
|
||||
assert_eq!(weight(t / 2, t, f, true, true), 1.0);
|
||||
assert!((0..t).all(|p| weight(p, t, f, true, true) > 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_overlapping_tiles_blend_to_a_partition_of_unity() {
|
||||
// at every cell of the shared region the normalised weights are in [0, 1] and sum to 1
|
||||
let (t, f) = (512usize, 64usize);
|
||||
let s = starts(900, t, 64);
|
||||
assert_eq!(s.len(), 2);
|
||||
for cell in s[1]..s[0] + t {
|
||||
let a = weight(cell - s[0], t, f, false, true);
|
||||
let b = weight(cell - s[1], t, f, true, false);
|
||||
let (na, nb) = (a / (a + b), b / (a + b));
|
||||
assert!((na + nb - 1.0).abs() < 1e-6 && na >= 0.0 && nb >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "lightcraft-denoise"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft AI denoise (L1): denoise-model manifests, Bayer packing, tiling and blending, the cache file, and pure-Rust CPU inference"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
runtime = ["dep:faer"]
|
||||
# Product-policy switch: absent by default; users may install their own model.
|
||||
rawnind-model = ["lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
# Safe matrix views over the pure-Rust GEMM backend; no BLAS, cc/build scripts
|
||||
# or inner thread pool. Its dependencies include architecture-specific assembly.
|
||||
faer = { package = "faer-core", version = "=0.17.1", default-features = false, features = ["std"], optional = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,18 @@
|
||||
# lightcraft-denoise
|
||||
|
||||
L1 pure-Rust AI RAW denoise primitives and CPU inference. No weights are bundled.
|
||||
See [user workflow and limitations](../../docs/denoise.md).
|
||||
|
||||
`lightcraft-denoise-core` provides metadata, Bayer helpers and cache formats,
|
||||
re-exported here. `manifest` and `known` validate opt-in models; `onnx` reads a bounded operator subset
|
||||
into `net`; `cpu` executes it and `reference` supplies scalar checks. `bayer`, `tiles`
|
||||
and `run` pack sensor data and blend overlapping tiles while preserving clipped
|
||||
highlights. `product` reads/writes the disposable `.lcdn` cache. `runtime` supplies
|
||||
the installation self-test. Synthetic networks are generated in Rust for tests.
|
||||
|
||||
Run `cargo test -p lightcraft-denoise --features runtime`. The CPU backend is optional;
|
||||
featureless and web builds keep the model metadata and cache format without faer/GEMM.
|
||||
The `rawnind-model` feature enables the otherwise empty download catalog and requires
|
||||
an explicit release-policy decision. Real-model tests are ignored by default and
|
||||
require an explicitly supplied local model. The GPU twin is `lightcraft-gpu::nn`;
|
||||
model management, background jobs and export integration live in `lightcraft-engine`.
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Single-thread CPU execution of the checked denoise network.
|
||||
//!
|
||||
//! Convolutions lower at most 256 spatial positions at a time, then use faer's safe views over pure-Rust GEMM.
|
||||
//! No inner thread pool: `run::denoise_bayer` already runs tiles on the application's Rayon pool. Tensor lifetimes
|
||||
//! are planned once; buffers and im2col storage are recycled between calls, independently for concurrent callers.
|
||||
|
||||
use crate::net::{Net, NetError, Op};
|
||||
use crate::run::{Error, TileRunner};
|
||||
use faer::{Parallelism, mat};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const BLOCK: usize = 256;
|
||||
/// Cap all tensor buffers and convolution scratch for one tile together (512 MiB).
|
||||
const MAX_VALUES: usize = 128 * 1024 * 1024;
|
||||
const MAX_TILE: usize = 2048;
|
||||
const MAX_IDLE: usize = 1;
|
||||
|
||||
type Result<T> = std::result::Result<T, NetError>;
|
||||
fn bad(why: &str) -> NetError {
|
||||
NetError::Invalid(why.into())
|
||||
}
|
||||
fn count(c: usize, side: usize) -> Result<usize> {
|
||||
c.checked_mul(side)
|
||||
.and_then(|n| n.checked_mul(side))
|
||||
.filter(|&n| n > 0 && n <= MAX_VALUES)
|
||||
.ok_or_else(|| bad("a tensor has no size or is too large"))
|
||||
}
|
||||
fn zeros(n: usize) -> Result<Vec<f32>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| bad("not enough memory for a denoise tile"))?;
|
||||
v.resize(n, 0.0);
|
||||
Ok(v)
|
||||
}
|
||||
fn sources(op: &Op) -> (usize, Option<usize>) {
|
||||
match op {
|
||||
Op::Conv(c) => (c.src, c.src2),
|
||||
Op::ConvT2(c) => (c.src, None),
|
||||
Op::MaxPool2 { src } | Op::DepthToSpace2 { src } => (*src, None),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Tensor {
|
||||
slot: usize,
|
||||
side: usize,
|
||||
channels: usize,
|
||||
}
|
||||
struct Plan {
|
||||
net: Net,
|
||||
tensors: Vec<Tensor>,
|
||||
sizes: Vec<usize>,
|
||||
lower: usize,
|
||||
product: usize,
|
||||
up_weights: Vec<Option<Vec<f32>>>,
|
||||
}
|
||||
struct Workspace {
|
||||
buffers: Vec<Vec<f32>>,
|
||||
lower: Vec<f32>,
|
||||
product: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A checked Net on square CHW tiles. Clones share immutable weights and the idle workspace cache.
|
||||
#[derive(Clone)]
|
||||
pub struct NetRunner {
|
||||
plan: Arc<Plan>,
|
||||
idle: Arc<Mutex<Vec<Workspace>>>,
|
||||
}
|
||||
|
||||
impl NetRunner {
|
||||
pub fn new(net: &Net, tile: usize) -> Result<Self> {
|
||||
if tile == 0
|
||||
|| tile > MAX_TILE
|
||||
|| !net.fits_tile(tile)
|
||||
|| net.in_channels != 4
|
||||
|| net.out_channels() != 3
|
||||
|| net.shape(0).is_none_or(|s| s.channels != net.in_channels)
|
||||
{
|
||||
return Err(bad("the tile or Bayer-to-RGB contract does not fit the network"));
|
||||
}
|
||||
let mut last = vec![0; net.ops().len() + 1];
|
||||
for (i, op) in net.ops().iter().enumerate() {
|
||||
let (a, b) = sources(op);
|
||||
for id in std::iter::once(a).chain(b) {
|
||||
*last.get_mut(id).ok_or_else(|| bad("missing tensor"))? = i + 1;
|
||||
}
|
||||
}
|
||||
let mut tensors = vec![Tensor { slot: 0, side: tile, channels: net.in_channels }];
|
||||
let mut sizes = vec![count(net.in_channels, tile)?];
|
||||
let mut busy = vec![true];
|
||||
let (mut lower, mut product) = (0, 0);
|
||||
let mut up_weights = Vec::new();
|
||||
for (i, op) in net.ops().iter().enumerate() {
|
||||
let (src, src2) = sources(op);
|
||||
let a = *tensors.get(src).ok_or_else(|| bad("missing tensor"))?;
|
||||
let (channels, side) = match op {
|
||||
Op::Conv(c) => {
|
||||
if let Some(s) = src2
|
||||
&& tensors.get(s).is_none_or(|b| b.side != a.side)
|
||||
{
|
||||
return Err(bad("a join has different sizes"));
|
||||
}
|
||||
(c.cout, a.side)
|
||||
}
|
||||
Op::ConvT2(c) => (c.cout, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
||||
Op::MaxPool2 { .. } => {
|
||||
if a.side < 2 || !a.side.is_multiple_of(2) {
|
||||
return Err(bad("pooling does not divide the tile"));
|
||||
}
|
||||
(a.channels, a.side / 2)
|
||||
}
|
||||
Op::DepthToSpace2 { .. } => (a.channels / 4, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
||||
};
|
||||
let n = count(channels, side)?;
|
||||
let slot = busy
|
||||
.iter()
|
||||
.zip(&sizes)
|
||||
.enumerate()
|
||||
.filter(|(_, (used, _))| !**used)
|
||||
.min_by_key(|(_, (_, size))| (n.saturating_sub(**size), **size))
|
||||
.map_or(busy.len(), |(i, _)| i);
|
||||
if slot == sizes.len() {
|
||||
sizes.push(n);
|
||||
busy.push(true);
|
||||
} else {
|
||||
let sz = sizes.get_mut(slot).ok_or_else(|| bad("missing buffer"))?;
|
||||
*sz = (*sz).max(n);
|
||||
*busy.get_mut(slot).ok_or_else(|| bad("missing buffer"))? = true;
|
||||
}
|
||||
tensors.push(Tensor { slot, side, channels });
|
||||
// Release sources only after the output has its own buffer (also handles reading the same tensor twice).
|
||||
for id in std::iter::once(src).chain(src2) {
|
||||
if last.get(id) == Some(&(i + 1)) {
|
||||
let t = tensors.get(id).ok_or_else(|| bad("missing tensor"))?;
|
||||
*busy.get_mut(t.slot).ok_or_else(|| bad("missing buffer"))? = false;
|
||||
}
|
||||
}
|
||||
let (k, m, positions) = match op {
|
||||
Op::Conv(c) => (c.cin * c.k * c.k, c.cout, a.side * a.side),
|
||||
Op::ConvT2(c) => (c.cin, c.cout * 4, a.side * a.side),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
lower = lower.max(k.saturating_mul(BLOCK.min(positions)));
|
||||
product = product.max(m.saturating_mul(BLOCK.min(positions)));
|
||||
let packed = if let Op::ConvT2(c) = op {
|
||||
let mut w = zeros(c.weight.len())?;
|
||||
// [cin][cout][dy][dx] -> [cout*4][cin], so an up-convolution is one GEMM and a shuffle.
|
||||
for (ci, row) in c.weight.chunks_exact(c.cout * 4).enumerate() {
|
||||
for (co, &v) in row.iter().enumerate() {
|
||||
*w.get_mut(co * c.cin + ci).ok_or_else(|| bad("invalid transposed weights"))? = v;
|
||||
}
|
||||
}
|
||||
Some(w)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
up_weights.push(packed);
|
||||
}
|
||||
let total = sizes
|
||||
.iter()
|
||||
.try_fold(lower.saturating_add(product), |n, &s| n.checked_add(s))
|
||||
.filter(|&n| n <= MAX_VALUES)
|
||||
.ok_or_else(|| bad("the tile needs too much working memory"))?;
|
||||
let _ = total;
|
||||
Ok(Self { plan: Arc::new(Plan { net: net.clone(), tensors, sizes, lower, product, up_weights }), idle: Arc::new(Mutex::new(Vec::new())) })
|
||||
}
|
||||
/// Immutable data for the GPU runner, without re-reading the model.
|
||||
pub fn net(&self) -> &Net {
|
||||
&self.plan.net
|
||||
}
|
||||
pub fn bytes_per_tile(&self) -> usize {
|
||||
(self.plan.sizes.iter().sum::<usize>() + self.plan.lower + self.plan.product) * 4
|
||||
}
|
||||
fn workspace(&self) -> Result<Workspace> {
|
||||
if let Some(w) = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() {
|
||||
return Ok(w);
|
||||
}
|
||||
Ok(Workspace {
|
||||
buffers: self.plan.sizes.iter().map(|&n| zeros(n)).collect::<Result<_>>()?,
|
||||
lower: zeros(self.plan.lower)?,
|
||||
product: zeros(self.plan.product)?,
|
||||
})
|
||||
}
|
||||
fn execute(&self, input: &[f32], w: &mut Workspace) -> Result<Vec<f32>> {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| bad("no input"))?;
|
||||
let want = count(first.channels, first.side)?;
|
||||
w.buffers.first_mut().and_then(|b| b.get_mut(..want)).ok_or_else(|| bad("missing input buffer"))?.copy_from_slice(input);
|
||||
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
for (i, op) in self.plan.net.ops().iter().enumerate() {
|
||||
let started = web_time::Instant::now();
|
||||
let out = *self.plan.tensors.get(i + 1).ok_or_else(|| bad("missing output tensor"))?;
|
||||
let (before, rest) = w.buffers.split_at_mut_checked(out.slot).ok_or_else(|| bad("missing output buffer"))?;
|
||||
let (dest, after) = rest.split_first_mut().ok_or_else(|| bad("missing output buffer"))?;
|
||||
let get = |id: usize| -> Result<(&[f32], Tensor)> {
|
||||
let t = *self.plan.tensors.get(id).ok_or_else(|| bad("missing source tensor"))?;
|
||||
let data = if t.slot < out.slot {
|
||||
before.get(t.slot)
|
||||
} else {
|
||||
after.get(t.slot.checked_sub(out.slot + 1).ok_or_else(|| bad("aliased buffers"))?)
|
||||
}
|
||||
.and_then(|b| b.get(..count(t.channels, t.side).ok()?))
|
||||
.ok_or_else(|| bad("missing source buffer"))?;
|
||||
Ok((data, t))
|
||||
};
|
||||
let (src, src2) = sources(op);
|
||||
let (a, shape) = get(src)?;
|
||||
let b = src2.map(get).transpose()?.map(|v| v.0);
|
||||
let dest = dest.get_mut(..count(out.channels, out.side)?).ok_or_else(|| bad("short output buffer"))?;
|
||||
match op {
|
||||
Op::Conv(c) => conv(c, a, b, shape.side, dest, &mut w.lower, &mut w.product)?,
|
||||
Op::ConvT2(c) => up(
|
||||
c,
|
||||
a,
|
||||
shape.side,
|
||||
dest,
|
||||
self.plan.up_weights.get(i).and_then(Option::as_deref).ok_or_else(|| bad("missing up weights"))?,
|
||||
&mut w.lower,
|
||||
&mut w.product,
|
||||
)?,
|
||||
Op::MaxPool2 { .. } => pool(a, shape.side, dest)?,
|
||||
Op::DepthToSpace2 { .. } => shuffle(a, shape.side, dest)?,
|
||||
}
|
||||
if dest.iter().any(|v| !v.is_finite()) {
|
||||
return Err(bad("the model produced non-finite values"));
|
||||
}
|
||||
if profile {
|
||||
eprintln!("[denoise-cpu] layer {i}: {}x{}x{} {:.2} ms", out.channels, out.side, out.side, started.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
let t = self.plan.tensors.last().ok_or_else(|| bad("no output"))?;
|
||||
let data = w.buffers.get(t.slot).and_then(|b| b.get(..count(t.channels, t.side).ok()?)).ok_or_else(|| bad("missing result"))?;
|
||||
let mut out = zeros(data.len())?;
|
||||
out.copy_from_slice(data);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
impl TileRunner for NetRunner {
|
||||
fn run(&self, input: &[f32]) -> std::result::Result<Vec<f32>, Error> {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| Error::Runtime("missing input shape".into()))?;
|
||||
let want = count(first.channels, first.side).map_err(|e| Error::Runtime(e.to_string()))?;
|
||||
if input.len() != want || input.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Input("the tile has the wrong size or non-finite values".into()));
|
||||
}
|
||||
let mut w = self.workspace().map_err(|e| Error::Runtime(e.to_string()))?;
|
||||
let result = self.execute(input, &mut w).map_err(|e| Error::Runtime(e.to_string()));
|
||||
let mut idle = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if idle.len() < MAX_IDLE {
|
||||
idle.push(w);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
fn activate(x: f32, bias: f32, alpha: Option<f32>) -> f32 {
|
||||
let x = x + bias;
|
||||
if x < 0.0 { x * alpha.unwrap_or(1.0) } else { x }
|
||||
}
|
||||
|
||||
/// All views are over exact, checked slices. The matrix library's assertions therefore never see file-derived sizes.
|
||||
fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -> Result<()> {
|
||||
if m.checked_mul(k) != Some(w.len()) || k.checked_mul(n) != Some(x.len()) || m.checked_mul(n) != Some(y.len()) || m == 0 || k == 0 || n == 0 {
|
||||
return Err(bad("invalid matrix dimensions"));
|
||||
}
|
||||
faer::mul::matmul(
|
||||
mat::from_row_major_slice_mut::<f32>(y, m, n),
|
||||
mat::from_row_major_slice::<f32>(w, m, k),
|
||||
mat::from_row_major_slice::<f32>(x, k, n),
|
||||
None,
|
||||
1.0,
|
||||
Parallelism::None,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn lower(a: &[f32], b: Option<&[f32]>, side: usize, k: usize, start: usize, n: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let pad = k / 2;
|
||||
let pixels = side * side;
|
||||
// Whole row segments are copied, so bounds checks and edge branches stay out of the multiply loop.
|
||||
for (ci, plane) in a.chunks_exact(pixels).chain(b.into_iter().flat_map(|b| b.chunks_exact(pixels))).enumerate() {
|
||||
for ky in 0..k {
|
||||
for kx in 0..k {
|
||||
let row = (ci * k * k + ky * k + kx) * n;
|
||||
let out = dest.get_mut(row..row + n).ok_or_else(|| bad("short im2col buffer"))?;
|
||||
let mut at = 0;
|
||||
while at < n {
|
||||
let p = start + at;
|
||||
let (y, x) = (p / side, p % side);
|
||||
let len = (side - x).min(n - at);
|
||||
if let Some(yy) = y.checked_add(ky).and_then(|v| v.checked_sub(pad)).filter(|&v| v < side) {
|
||||
let lo = x.max(pad.saturating_sub(kx));
|
||||
let hi = (x + len).min((side + pad).saturating_sub(kx));
|
||||
if lo < hi {
|
||||
let from = yy * side + lo + kx - pad;
|
||||
let to = at + lo - x;
|
||||
out.get_mut(at..to).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
||||
out.get_mut(to..to + hi - lo)
|
||||
.ok_or_else(|| bad("short im2col row"))?
|
||||
.copy_from_slice(plane.get(from..from + hi - lo).ok_or_else(|| bad("short source row"))?);
|
||||
out.get_mut(to + hi - lo..at + len).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
||||
} else {
|
||||
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
||||
}
|
||||
} else {
|
||||
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn conv(c: &crate::net::Conv, a: &[f32], b: Option<&[f32]>, side: usize, dest: &mut [f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
let k = c.cin * c.k * c.k;
|
||||
for start in (0..pixels).step_by(BLOCK) {
|
||||
let n = BLOCK.min(pixels - start);
|
||||
let x = scratch.get_mut(..k * n).ok_or_else(|| bad("short im2col storage"))?;
|
||||
lower(a, b, side, c.k, start, n, x)?;
|
||||
let y = product.get_mut(..c.cout * n).ok_or_else(|| bad("short product storage"))?;
|
||||
multiply(&c.weight, x, y, c.cout, k, n)?;
|
||||
for ((row, &bias), out) in y.chunks_exact(n).zip(&c.bias).zip(dest.chunks_exact_mut(pixels)) {
|
||||
let to = out.get_mut(start..start + n).ok_or_else(|| bad("short convolution output"))?;
|
||||
for (d, &v) in to.iter_mut().zip(row) {
|
||||
*d = activate(v, bias, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn up(c: &crate::net::ConvT2, a: &[f32], side: usize, dest: &mut [f32], weights: &[f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
let out_side = 2 * side;
|
||||
for start in (0..pixels).step_by(BLOCK) {
|
||||
let n = BLOCK.min(pixels - start);
|
||||
let x = scratch.get_mut(..c.cin * n).ok_or_else(|| bad("short up input storage"))?;
|
||||
for (to, from) in x.chunks_exact_mut(n).zip(a.chunks_exact(pixels)) {
|
||||
to.copy_from_slice(from.get(start..start + n).ok_or_else(|| bad("short up input"))?);
|
||||
}
|
||||
let y = product.get_mut(..c.cout * 4 * n).ok_or_else(|| bad("short up product storage"))?;
|
||||
multiply(weights, x, y, c.cout * 4, c.cin, n)?;
|
||||
for (co, (&bias, out)) in c.bias.iter().zip(dest.chunks_exact_mut(4 * pixels)).enumerate() {
|
||||
for d in 0..4 {
|
||||
let row = y.get((4 * co + d) * n..(4 * co + d + 1) * n).ok_or_else(|| bad("short up product"))?;
|
||||
for (j, &v) in row.iter().enumerate() {
|
||||
let p = start + j;
|
||||
let at = (2 * (p / side) + d / 2) * out_side + 2 * (p % side) + d % 2;
|
||||
*out.get_mut(at).ok_or_else(|| bad("short up output"))? = activate(v, bias, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn pool(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let s = side / 2;
|
||||
for (input, out) in a.chunks_exact(side * side).zip(dest.chunks_exact_mut(s * s)) {
|
||||
for (y, row) in out.chunks_exact_mut(s).enumerate() {
|
||||
let lo = input.get(2 * y * side..(2 * y + 1) * side).ok_or_else(|| bad("short pool input"))?;
|
||||
let hi = input.get((2 * y + 1) * side..(2 * y + 2) * side).ok_or_else(|| bad("short pool input"))?;
|
||||
for ((d, lo), hi) in row.iter_mut().zip(lo.as_chunks::<2>().0).zip(hi.as_chunks::<2>().0) {
|
||||
let [a, b] = *lo;
|
||||
let [c, e] = *hi;
|
||||
*d = a.max(b).max(c).max(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn shuffle(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
for (input, out) in a.chunks_exact(4 * pixels).zip(dest.chunks_exact_mut(4 * pixels)) {
|
||||
for d in 0..4 {
|
||||
let plane = input.get(d * pixels..(d + 1) * pixels).ok_or_else(|| bad("short shuffle input"))?;
|
||||
for (y, row) in plane.chunks_exact(side).enumerate() {
|
||||
let at = (2 * y + d / 2) * 2 * side;
|
||||
let to = out.get_mut(at..at + 2 * side).ok_or_else(|| bad("short shuffle output"))?;
|
||||
for (to, &v) in to.as_chunks_mut::<2>().0.iter_mut().zip(row) {
|
||||
if let Some(t) = to.get_mut(d % 2) {
|
||||
*t = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,151 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
net::{Conv, ConvT2},
|
||||
reference, synthetic,
|
||||
};
|
||||
|
||||
fn random(n: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed | 1;
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s >> 8) as f32 / (1u32 << 23) as f32 - 1.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn net(tile: usize, ch: u64, depth: usize, seed: u64) -> Net {
|
||||
let p = std::env::temp_dir().join(format!("lc-purecpu-{}-{tile}-{ch}-{depth}-{seed}.onnx", std::process::id()));
|
||||
std::fs::write(&p, synthetic::unet_onnx(tile as u64, ch, depth, seed)).unwrap();
|
||||
let n = crate::onnx::read(&p).unwrap();
|
||||
std::fs::remove_file(p).unwrap();
|
||||
n
|
||||
}
|
||||
fn agree(a: &[f32], b: &[f32]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert!(a.iter().chain(b).all(|v| v.is_finite()));
|
||||
let scale = b.iter().fold(1e-6f32, |m, v| m.max(v.abs()));
|
||||
let error = a.iter().zip(b).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
|
||||
assert!(error <= 1e-3, "relative maximum error {error:e}");
|
||||
}
|
||||
#[test]
|
||||
fn all_operators_match_scalar_on_random_nets_and_odd_and_tiny_shapes() {
|
||||
for (t, ch, d) in [(1, 3, 0), (3, 5, 0), (5, 7, 0), (6, 3, 1), (20, 5, 2), (36, 9, 2), (64, 8, 3)] {
|
||||
for seed in [1, 73] {
|
||||
let n = net(t, ch, d, seed);
|
||||
let r = NetRunner::new(&n, t).unwrap();
|
||||
let x = random(4 * t * t, seed as u32);
|
||||
let want = reference::run(&n, t, &x).unwrap();
|
||||
agree(&r.run(&x).unwrap(), &want);
|
||||
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_values_fail_before_execution_and_workspace_sizes_are_bounded() {
|
||||
let mut n = net(4, 3, 1, 11);
|
||||
for t in [0, 1, 3, usize::MAX, 4096] {
|
||||
assert!(NetRunner::new(&n, t).is_err());
|
||||
}
|
||||
for ch in [0, 3, usize::MAX] {
|
||||
n.in_channels = ch;
|
||||
assert!(NetRunner::new(&n, 4).is_err());
|
||||
}
|
||||
let n = Net::new(
|
||||
4,
|
||||
vec![
|
||||
Op::Conv(Conv { src: 0, src2: None, cin: 4, cout: 4096, k: 1, weight: vec![0.1; 4 * 4096], bias: vec![0.0; 4096], leaky: None }),
|
||||
Op::Conv(Conv { src: 1, src2: None, cin: 4096, cout: 12, k: 1, weight: vec![0.1; 4096 * 12], bias: vec![0.0; 12], leaky: None }),
|
||||
Op::DepthToSpace2 { src: 2 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(NetRunner::new(&n, 2048).is_err());
|
||||
let ops = (0..100).map(|i| Op::MaxPool2 { src: i }).collect();
|
||||
let n = Net::new(4, ops).unwrap();
|
||||
assert!(NetRunner::new(&n, 16).is_err());
|
||||
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![0.25; 48], bias: vec![0.0; 12], leaky: None };
|
||||
for mode in 0..6 {
|
||||
let mut bad = c.clone();
|
||||
match mode {
|
||||
0 => bad.src = usize::MAX,
|
||||
1 => bad.weight.pop().map(|_| ()).unwrap(),
|
||||
2 => bad.bias[0] = f32::NAN,
|
||||
3 => bad.leaky = Some(f32::INFINITY),
|
||||
4 => bad.cout = usize::MAX,
|
||||
_ => bad.k = usize::MAX,
|
||||
}
|
||||
assert!(Net::new(4, vec![Op::Conv(bad), Op::DepthToSpace2 { src: 1 }]).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bad_tiles_and_arithmetic_overflow_are_errors_and_do_not_poison_reuse() {
|
||||
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![f32::MAX; 48], bias: vec![0.0; 12], leaky: None };
|
||||
let n = Net::new(4, vec![Op::Conv(c), Op::DepthToSpace2 { src: 1 }]).unwrap();
|
||||
let r = NetRunner::new(&n, 1).unwrap();
|
||||
assert!(r.run(&[]).is_err());
|
||||
assert!(r.run(&[f32::NAN; 4]).is_err());
|
||||
assert!(r.run(&[f32::INFINITY; 4]).is_err());
|
||||
assert_eq!(r.idle.lock().unwrap().len(), 0, "invalid input never allocates a workspace");
|
||||
assert!(r.run(&[2.0; 4]).is_err());
|
||||
assert_eq!(r.run(&[0.0; 4]).unwrap(), vec![0.0; 12]);
|
||||
// Matrix shape checks are the boundary in front of the dependency's asserting constructors.
|
||||
assert!(multiply(&[], &[], &mut [], usize::MAX, 2, 2).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn shared_runner_serves_concurrent_tiles_without_cross_contamination() {
|
||||
let n = net(12, 5, 1, 97);
|
||||
let r = NetRunner::new(&n, 12).unwrap();
|
||||
let inputs: Vec<_> = (0..8).map(|s| random(4 * 12 * 12, s)).collect();
|
||||
let answers: Vec<_> = inputs.iter().map(|x| reference::run(&n, 12, x).unwrap()).collect();
|
||||
std::thread::scope(|scope| {
|
||||
let mut handles = Vec::new();
|
||||
for (x, want) in inputs.iter().zip(&answers) {
|
||||
let r = &r;
|
||||
handles.push(scope.spawn(move || {
|
||||
for _ in 0..3 {
|
||||
agree(&r.run(x).unwrap(), want);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn join_may_read_the_same_tensor_twice_and_leaky_up_convolution_is_supported() {
|
||||
let c = ConvT2 { src: 0, cin: 4, cout: 3, weight: random(48, 2), bias: vec![0.1, -0.1, 0.0], leaky: Some(0.13) };
|
||||
let n = Net::new(
|
||||
4,
|
||||
vec![
|
||||
Op::ConvT2(c),
|
||||
Op::Conv(Conv { src: 1, src2: Some(1), cin: 6, cout: 3, k: 3, weight: random(162, 3), bias: vec![0.0; 3], leaky: Some(0.0) }),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let x = random(4 * 3 * 3, 7);
|
||||
agree(&NetRunner::new(&n, 3).unwrap().run(&x).unwrap(), &reference::run(&n, 3, &x).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_im2col_clears_all_edge_padding_between_images() {
|
||||
for t in [1, 3, 5, 36] {
|
||||
let n = net(t, 5, 0, 121);
|
||||
let r = NetRunner::new(&n, t).unwrap();
|
||||
for x in [vec![1.0; 4 * t * t], vec![0.0; 4 * t * t], random(4 * t * t, 9)] {
|
||||
agree(&r.run(&x).unwrap(), &reference::run(&n, t, &x).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs LC_DENOISE_MODEL; one tile with LIGHTCRAFT_PROFILE=1"]
|
||||
fn profile_one_real_tile() {
|
||||
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
|
||||
let m = crate::known::find("rawnind-bayer").unwrap().manifest;
|
||||
let r = crate::runtime::CpuRunner::load(std::path::Path::new(&model), &m).unwrap();
|
||||
println!("workspace {:.1} MB", r.bytes_per_tile() as f64 / 1e6);
|
||||
r.run(&crate::runtime::check_tile(512)).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! AI denoise for LightCraft, as a step between the sensor and the demosaic that the rest of the develop
|
||||
//! pipeline never sees: the raw file stays untouched, the denoised picture is cached data that can always be
|
||||
//! made again, and the amount is an ordinary develop setting.
|
||||
//!
|
||||
//! - [`manifest`]: what a denoise model takes and gives, and who may use it (untrusted JSON, validated).
|
||||
//! - [`known`] and [`archive`]: the models LightCraft can fetch, and taking the model file out of the `.zip` it comes in.
|
||||
//! - [`bayer`]: Bayer layouts and the packing of a mosaic into the four planes a model takes.
|
||||
//! - [`tiles`] and [`run`]: cutting a picture into overlapping tiles for a fixed-size model, bringing each tile's
|
||||
//! output scale back to the input's, keeping clipped highlights as they were, and blending the tiles together.
|
||||
//! The model itself is a [`run::TileRunner`], so all of this is tested without one.
|
||||
//! - [`product`]: the cache file the denoised picture is kept in (half floats, compressed in strips) and the
|
||||
//! blend with the plain demosaic that the Amount slider controls.
|
||||
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
|
||||
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
|
||||
//!
|
||||
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
|
||||
//! be finite, and a malformed file is an error, never a panic.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod cpu;
|
||||
|
||||
pub mod net;
|
||||
pub mod onnx;
|
||||
mod onnx_proto;
|
||||
|
||||
pub mod reference;
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod runtime;
|
||||
#[doc(hidden)]
|
||||
pub mod synthetic;
|
||||
|
||||
pub use lightcraft_denoise_core::*;
|
||||
|
||||
pub mod synthetic_proto;
|
||||
@@ -0,0 +1,345 @@
|
||||
//! A denoise network as plain data: the handful of layer kinds a U-Net denoiser is made of, with their weights.
|
||||
//!
|
||||
//! [`onnx`](crate::onnx) reads the file into this description, shared by the pure-Rust CPU and GPU runners.
|
||||
//! Unsupported operators, groups, dilation or activations are reported as errors when loading the model.
|
||||
//!
|
||||
//! The description is checked ([`Net::new`]) because it comes from a stranger's file: channel counts must chain,
|
||||
//! weights must have the sizes the shapes need and be finite, and the depth must stay small.
|
||||
|
||||
/// Most layers a network may have.
|
||||
pub const MAX_OPS: usize = 512;
|
||||
/// Most channels a layer may have.
|
||||
pub const MAX_CHANNELS: usize = 4096;
|
||||
/// Most weights (numbers) a network may have in all.
|
||||
pub const MAX_WEIGHTS: usize = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum NetError {
|
||||
/// The model uses something this denoise network description cannot express.
|
||||
#[error("not supported by the denoise runners: {0}")]
|
||||
Unsupported(String),
|
||||
/// The model is not a well-formed network.
|
||||
#[error("not a valid network: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Invalid(why.into()))
|
||||
}
|
||||
|
||||
/// A tensor is named by its number: 0 is the network's input, and layer `i` makes tensor `i + 1`.
|
||||
pub type TensorId = usize;
|
||||
|
||||
/// A convolution with stride 1 and "same" padding (`k` is 1 or 3), over one tensor or two joined along the channels
|
||||
/// (`src` first), then a bias and optionally a leaky ReLU.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Conv {
|
||||
pub src: TensorId,
|
||||
/// A second source, whose channels follow `src`'s (a skip connection's concatenation).
|
||||
pub src2: Option<TensorId>,
|
||||
pub cin: usize,
|
||||
pub cout: usize,
|
||||
pub k: usize,
|
||||
/// `[cout][cin][k][k]`, as ONNX has it.
|
||||
pub weight: Vec<f32>,
|
||||
pub bias: Vec<f32>,
|
||||
/// `Some(alpha)`: a leaky ReLU with this slope follows.
|
||||
pub leaky: Option<f32>,
|
||||
}
|
||||
|
||||
/// A transposed convolution with a 2 × 2 kernel and stride 2: every input pixel becomes a 2 × 2 block.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConvT2 {
|
||||
pub src: TensorId,
|
||||
pub cin: usize,
|
||||
pub cout: usize,
|
||||
/// `[cin][cout][2][2]`, as ONNX has it.
|
||||
pub weight: Vec<f32>,
|
||||
pub bias: Vec<f32>,
|
||||
pub leaky: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Op {
|
||||
Conv(Conv),
|
||||
ConvT2(ConvT2),
|
||||
/// 2 × 2 maximum, stride 2.
|
||||
MaxPool2 {
|
||||
src: TensorId,
|
||||
},
|
||||
/// Depth-to-space with block size 2 in "CRD" order: output channel `c` at `(2y + dy, 2x + dx)` is input channel
|
||||
/// `4c + 2dy + dx` at `(y, x)`.
|
||||
DepthToSpace2 {
|
||||
src: TensorId,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a tensor looks like: its channels, and its resolution as a power of two below the input's (a negative
|
||||
/// level is above it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Shape {
|
||||
pub channels: usize,
|
||||
pub level: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Net {
|
||||
pub in_channels: usize,
|
||||
ops: Vec<Op>,
|
||||
shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl Net {
|
||||
/// Check `ops` (layer `i` makes tensor `i + 1`) over an input of `in_channels` and describe every tensor.
|
||||
pub fn new(in_channels: usize, ops: Vec<Op>) -> Result<Net, NetError> {
|
||||
if in_channels == 0 || in_channels > MAX_CHANNELS {
|
||||
return invalid("the input has no channels or too many");
|
||||
}
|
||||
if ops.is_empty() || ops.len() > MAX_OPS {
|
||||
return invalid("the network has no layers or too many");
|
||||
}
|
||||
let mut shapes = vec![Shape { channels: in_channels, level: 0 }];
|
||||
let mut weights = 0usize;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let shape_of = |t: TensorId| {
|
||||
shapes
|
||||
.get(t)
|
||||
.copied()
|
||||
.filter(|_| t <= i)
|
||||
.ok_or_else(|| NetError::Invalid(format!("layer {i} reads tensor {t}, which does not exist yet")))
|
||||
};
|
||||
let out = match op {
|
||||
Op::Conv(c) => {
|
||||
let a = shape_of(c.src)?;
|
||||
let (cin, level) = match c.src2 {
|
||||
Some(s2) => {
|
||||
let b = shape_of(s2)?;
|
||||
if b.level != a.level {
|
||||
return invalid(format!("layer {i} joins tensors of different resolutions"));
|
||||
}
|
||||
(a.channels + b.channels, a.level)
|
||||
}
|
||||
None => (a.channels, a.level),
|
||||
};
|
||||
if c.cin != cin || c.cout == 0 || c.cout > MAX_CHANNELS || !(c.k == 1 || c.k == 3) {
|
||||
return invalid(format!(
|
||||
"layer {i}: a convolution of {} to {} channels with a {}-wide kernel does not fit",
|
||||
c.cin, c.cout, c.k
|
||||
));
|
||||
}
|
||||
check_weights(
|
||||
i,
|
||||
&c.weight,
|
||||
c.cout.checked_mul(cin).and_then(|n| n.checked_mul(c.k * c.k)),
|
||||
&c.bias,
|
||||
c.cout,
|
||||
c.leaky,
|
||||
&mut weights,
|
||||
)?;
|
||||
Shape { channels: c.cout, level }
|
||||
}
|
||||
Op::ConvT2(c) => {
|
||||
let a = shape_of(c.src)?;
|
||||
if c.cin != a.channels || c.cout == 0 || c.cout > MAX_CHANNELS {
|
||||
return invalid(format!("layer {i}: a transposed convolution of {} to {} channels does not fit", c.cin, c.cout));
|
||||
}
|
||||
check_weights(i, &c.weight, c.cin.checked_mul(c.cout).and_then(|n| n.checked_mul(4)), &c.bias, c.cout, c.leaky, &mut weights)?;
|
||||
Shape { channels: c.cout, level: a.level - 1 }
|
||||
}
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = shape_of(*src)?;
|
||||
Shape { channels: a.channels, level: a.level + 1 }
|
||||
}
|
||||
Op::DepthToSpace2 { src } => {
|
||||
let a = shape_of(*src)?;
|
||||
if !a.channels.is_multiple_of(4) {
|
||||
return invalid(format!("layer {i}: depth-to-space needs a multiple of 4 channels"));
|
||||
}
|
||||
Shape { channels: a.channels / 4, level: a.level - 1 }
|
||||
}
|
||||
};
|
||||
shapes.push(out);
|
||||
}
|
||||
Ok(Net { in_channels, ops, shapes })
|
||||
}
|
||||
|
||||
/// The layers: layer `i` makes tensor `i + 1`.
|
||||
pub fn ops(&self) -> &[Op] {
|
||||
&self.ops
|
||||
}
|
||||
|
||||
/// The shape of tensor `t` (0 is the input).
|
||||
pub fn shape(&self, t: TensorId) -> Option<Shape> {
|
||||
self.shapes.get(t).copied()
|
||||
}
|
||||
|
||||
/// The tensor the network gives (the last layer's).
|
||||
pub fn output(&self) -> TensorId {
|
||||
self.ops.len()
|
||||
}
|
||||
|
||||
pub fn out_channels(&self) -> usize {
|
||||
self.shapes.last().map_or(0, |s| s.channels)
|
||||
}
|
||||
|
||||
/// The deepest resolution level: a tile must be a multiple of `2^this` cells wide.
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.shapes.iter().map(|s| s.level.max(0) as u32).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Can the network run on a `tile × tile` input and give `[out_channels, 2·tile, 2·tile]`? (Pooling must divide
|
||||
/// the tile, and the output must be one level above the input.)
|
||||
pub fn fits_tile(&self, tile: usize) -> bool {
|
||||
let Some(multiple) = 1usize.checked_shl(self.depth()) else { return false };
|
||||
tile > 0 && tile.is_multiple_of(multiple) && self.shapes.last().is_some_and(|s| s.level == -1)
|
||||
}
|
||||
|
||||
/// Multiply-adds for one tile, for estimating time.
|
||||
pub fn macs(&self, tile: usize) -> u64 {
|
||||
let pixels = |level: i32| -> u64 {
|
||||
let side = if level >= 0 {
|
||||
tile.checked_shr(level as u32).unwrap_or(0)
|
||||
} else {
|
||||
tile.saturating_mul(1usize.checked_shl(level.unsigned_abs()).unwrap_or(usize::MAX))
|
||||
};
|
||||
(side as u64).saturating_mul(side as u64)
|
||||
};
|
||||
self.ops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, op)| {
|
||||
let out = self.shapes.get(i + 1).map_or(0, |s| pixels(s.level));
|
||||
match op {
|
||||
Op::Conv(c) => out.saturating_mul((c.cin * c.cout * c.k * c.k) as u64),
|
||||
// every input pixel makes a 2 × 2 block: as many multiply-adds as the 2 × 2-wide kernel has weights
|
||||
Op::ConvT2(c) => pixels(self.shapes.get(c.src).map_or(0, |s| s.level)).saturating_mul((c.cin * c.cout * 4) as u64),
|
||||
_ => 0,
|
||||
}
|
||||
})
|
||||
.fold(0u64, u64::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_weights(
|
||||
layer: usize,
|
||||
weight: &[f32],
|
||||
want: Option<usize>,
|
||||
bias: &[f32],
|
||||
cout: usize,
|
||||
leaky: Option<f32>,
|
||||
total: &mut usize,
|
||||
) -> Result<(), NetError> {
|
||||
if want != Some(weight.len()) || bias.len() != cout {
|
||||
return invalid(format!("layer {layer}: the weights are not the size its shape needs"));
|
||||
}
|
||||
*total = total.saturating_add(weight.len()).saturating_add(bias.len());
|
||||
if *total > MAX_WEIGHTS {
|
||||
return invalid("the network has too many weights");
|
||||
}
|
||||
if weight.iter().chain(bias).any(|v| !v.is_finite()) || leaky.is_some_and(|a| !a.is_finite()) {
|
||||
return invalid(format!("layer {layer}: a weight is not a finite number"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn conv(src: TensorId, src2: Option<TensorId>, cin: usize, cout: usize, k: usize) -> Op {
|
||||
Op::Conv(Conv { src, src2, cin, cout, k, weight: vec![0.1; cout * cin * k * k], bias: vec![0.0; cout], leaky: Some(0.2) })
|
||||
}
|
||||
|
||||
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
|
||||
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
|
||||
}
|
||||
|
||||
/// A two-level network of the shape the denoisers have: 4 → 8 channels, pool, 16 channels, up, join, 12, and the
|
||||
/// 1 × 1 convolution to the colours.
|
||||
fn unet() -> Vec<Op> {
|
||||
vec![
|
||||
conv(0, None, 4, 8, 3), // 1: 8 ch, level 0
|
||||
Op::MaxPool2 { src: 1 }, // 2: level 1
|
||||
conv(2, None, 8, 16, 3), // 3: 16 ch, level 1
|
||||
convt(3, 16, 8), // 4: 8 ch, level 0
|
||||
conv(4, Some(1), 16, 8, 3), // 5: join [up, skip]
|
||||
conv(5, None, 8, 12, 1), // 6: 12 ch
|
||||
Op::DepthToSpace2 { src: 6 }, // 7: 3 ch, level -1
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_networks_and_huge_cost_queries_never_overflow_or_truncate_depth() {
|
||||
let n = Net::new(4, unet()).unwrap();
|
||||
assert_eq!(n.macs(usize::MAX), u64::MAX);
|
||||
assert_eq!(n.macs(0), 0);
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..21 {
|
||||
ops.push(Op::MaxPool2 { src: i });
|
||||
}
|
||||
for i in 21..42 {
|
||||
ops.push(convt(i, 4, 4));
|
||||
}
|
||||
ops.push(conv(42, None, 4, 12, 1));
|
||||
ops.push(Op::DepthToSpace2 { src: 43 });
|
||||
let n = Net::new(4, ops).unwrap();
|
||||
assert!(!n.fits_tile(1 << 20));
|
||||
assert!(n.fits_tile(1 << 21));
|
||||
let _ = n.macs(16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u_net_is_described_with_its_shapes_and_costs() {
|
||||
let net = Net::new(4, unet()).unwrap();
|
||||
assert_eq!(net.out_channels(), 3);
|
||||
assert_eq!(net.shape(3), Some(Shape { channels: 16, level: 1 }));
|
||||
assert_eq!(net.shape(7), Some(Shape { channels: 3, level: -1 }));
|
||||
assert_eq!(net.depth(), 1);
|
||||
assert!(net.fits_tile(64) && !net.fits_tile(63) && !net.fits_tile(0));
|
||||
// 64 × 64 cells: layer 1 = 4096·4·8·9, and so on; just check it is the sum of what the layers cost
|
||||
let t = 64u64;
|
||||
let want = t * t * 4 * 8 * 9 + (t / 2) * (t / 2) * 8 * 16 * 9 + (t / 2) * (t / 2) * 16 * 8 * 4 + t * t * 16 * 8 * 9 + t * t * 8 * 12;
|
||||
assert_eq!(net.macs(64), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_that_does_not_chain_is_refused() {
|
||||
// wrong input channel count for the first convolution
|
||||
assert!(Net::new(3, unet()).is_err());
|
||||
// a layer reading a tensor that does not exist yet
|
||||
let mut ops = unet();
|
||||
ops[2] = conv(9, None, 8, 16, 3);
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// joining tensors of different resolutions
|
||||
let mut ops = unet();
|
||||
ops[4] = conv(3, Some(1), 24, 8, 3);
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// weights of the wrong size, and not finite
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.weight.pop();
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.bias[0] = f32::NAN;
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// a kernel that is neither 1 nor 3 wide, no layers, depth-to-space on a channel count it cannot split
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.k = 5;
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
assert!(Net::new(4, vec![]).is_err());
|
||||
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }]).is_ok());
|
||||
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }, Op::DepthToSpace2 { src: 1 }]).is_err());
|
||||
assert!(Net::new(2, vec![Op::DepthToSpace2 { src: 0 }]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_that_does_not_end_one_level_up_does_not_fit_a_tile() {
|
||||
let net = Net::new(4, unet()[..6].to_vec()).unwrap();
|
||||
assert!(!net.fits_tile(64), "no depth-to-space: the output is not at twice the resolution");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Reading an `.onnx` file into a [`Net`] with bounded, pure-Rust protobuf decoding.
|
||||
//!
|
||||
//! Only what the denoise U-Nets are made of is understood: `Conv` (3 × 3 "same" or 1 × 1, stride 1, no groups, no
|
||||
//! dilation) optionally followed by `LeakyRelu`, `ConvTranspose` (2 × 2, stride 2) likewise, `MaxPool` (2 × 2, stride 2),
|
||||
//! a two-input `Concat` on the channels that feeds a convolution, and a final `DepthToSpace` (2, `CRD`). Any other
|
||||
//! operator or attribute is [`NetError::Unsupported`], which is reported to the user by both runners.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::onnx_proto::{self, AttributeProto, NodeProto, TensorProto, ValueInfoProto};
|
||||
use std::io::Read;
|
||||
|
||||
use crate::net::{Conv, ConvT2, MAX_WEIGHTS, Net, NetError, Op, TensorId};
|
||||
|
||||
fn unsupported<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Unsupported(why.into()))
|
||||
}
|
||||
|
||||
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Invalid(why.into()))
|
||||
}
|
||||
|
||||
/// Read the model at `path`.
|
||||
pub fn read(path: &Path) -> Result<Net, NetError> {
|
||||
let file = std::fs::File::open(path).map_err(|e| NetError::Invalid(e.to_string()))?;
|
||||
let size = usize::try_from(file.metadata().map_err(|e| NetError::Invalid(e.to_string()))?.len())
|
||||
.ok()
|
||||
.filter(|&n| n <= onnx_proto::MAX_BYTES)
|
||||
.ok_or_else(|| NetError::Invalid("model file too large".into()))?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(size).map_err(|_| NetError::Invalid("not enough memory to load the model".into()))?;
|
||||
file.take(onnx_proto::MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| NetError::Invalid(e.to_string()))?;
|
||||
read_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
|
||||
let graph = onnx_proto::decode(bytes)?;
|
||||
|
||||
let initialisers: HashMap<&str, &TensorProto> = graph.initializer.iter().map(|t| (t.name.as_str(), t)).collect();
|
||||
if initialisers.len() != graph.initializer.len() {
|
||||
return invalid("duplicate initializer names");
|
||||
}
|
||||
let inputs: Vec<_> = graph.input.iter().filter(|i| !initialisers.contains_key(i.name.as_str())).collect();
|
||||
let ([input], [output]) = (inputs.as_slice(), graph.output.as_slice()) else { return unsupported("not one input and one output") };
|
||||
let in_channels = match channel_dim(input) {
|
||||
Some(c) => c,
|
||||
None => return unsupported("the input's channel count is not written in the file"),
|
||||
};
|
||||
|
||||
// how many nodes read each name (a graph output counts as a reader)
|
||||
let mut readers: HashMap<&str, usize> = HashMap::new();
|
||||
for n in &graph.node {
|
||||
for i in &n.input {
|
||||
*readers.entry(i.as_str()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
*readers.entry(output.name.as_str()).or_default() += 1;
|
||||
|
||||
let mut ids: HashMap<String, TensorId> = HashMap::new();
|
||||
ids.insert(input.name.clone(), 0);
|
||||
// a Concat is not a layer of its own: the convolution that reads it takes both sources
|
||||
let mut joins: HashMap<String, (TensorId, TensorId)> = HashMap::new();
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
let mut weights_total = 0usize;
|
||||
|
||||
let nodes = &graph.node;
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let Some(node) = nodes.get(i) else { break };
|
||||
i += 1;
|
||||
if node.output.len() != 1 || node.output.first().is_none_or(|n| n.is_empty() || ids.contains_key(n) || joins.contains_key(n)) {
|
||||
return invalid("a layer has missing, repeated or multiple outputs");
|
||||
}
|
||||
let allowed: &[&str] = match node.op_type.as_str() {
|
||||
"Conv" => &["kernel_shape", "pads", "strides", "dilations", "group", "auto_pad"],
|
||||
"ConvTranspose" => &["kernel_shape", "pads", "strides", "dilations", "group", "output_padding", "auto_pad"],
|
||||
"MaxPool" => &["kernel_shape", "pads", "strides", "dilations", "ceil_mode", "storage_order", "auto_pad"],
|
||||
"Concat" => &["axis"],
|
||||
"DepthToSpace" => &["blocksize", "mode"],
|
||||
_ => &[],
|
||||
};
|
||||
if node.attribute.iter().any(|a| !allowed.contains(&a.name.as_str())) {
|
||||
return unsupported("an unknown operator attribute");
|
||||
}
|
||||
let this = ops.len() + 1;
|
||||
let lookup = |name: &str| -> Result<TensorId, NetError> {
|
||||
ids.get(name).copied().ok_or_else(|| NetError::Invalid(format!("`{}` reads `{name}`, which nothing makes", node.op_type)))
|
||||
};
|
||||
// a LeakyRelu straight after this layer, which nothing else reads, is part of it
|
||||
let mut fuse_leaky = |out: &str| -> Result<(Option<f32>, String), NetError> {
|
||||
if let Some(next) = nodes.get(i)
|
||||
&& next.op_type == "LeakyRelu"
|
||||
&& next.input.first().map(String::as_str) == Some(out)
|
||||
&& readers.get(out).copied() == Some(1)
|
||||
{
|
||||
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);
|
||||
i += 1;
|
||||
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
|
||||
return Ok((Some(alpha), name));
|
||||
}
|
||||
Ok((None, out.to_string()))
|
||||
};
|
||||
match node.op_type.as_str() {
|
||||
"Concat" => {
|
||||
if attr(node, "axis").map(|a| a.i) != Some(1) {
|
||||
return unsupported("Concat on another axis than the channels");
|
||||
}
|
||||
let [a, b] = node.input.as_slice() else { return unsupported("Concat of other than two tensors") };
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("Concat without an output".into()))?;
|
||||
if readers.get(out.as_str()).copied() != Some(1) {
|
||||
return unsupported("a Concat that is read by more than one layer");
|
||||
}
|
||||
joins.insert(out.clone(), (lookup(a)?, lookup(b)?));
|
||||
}
|
||||
"Conv" => {
|
||||
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
|
||||
let (cout, cin, kh, kw) = four(&w.0)?;
|
||||
let b = if b.is_empty() { vec![0.0; cout] } else { b };
|
||||
if kh != kw || !(kh == 1 || kh == 3) {
|
||||
return unsupported(format!("a {kh} × {kw} convolution"));
|
||||
}
|
||||
if ints_or(node, "strides", &[1, 1]) != [1, 1]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "group").is_some_and(|a| a.i != 1)
|
||||
{
|
||||
return unsupported("a convolution with strides, dilation or groups");
|
||||
}
|
||||
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [kh as i64, kw as i64]) {
|
||||
return invalid("kernel shape does not match the weights");
|
||||
}
|
||||
let pad = (kh as i64 - 1) / 2;
|
||||
if ints_or(node, "pads", &[0; 4]) != [pad; 4] || attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
|
||||
return unsupported("a convolution that is not padded to keep its size");
|
||||
}
|
||||
let src_name = node.input.first().ok_or_else(|| NetError::Invalid("Conv without an input".into()))?;
|
||||
let (src, src2) = match joins.remove(src_name) {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (lookup(src_name)?, None),
|
||||
};
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("Conv without an output".into()))?;
|
||||
let (leaky, out_name) = fuse_leaky(out)?;
|
||||
ids.insert(out_name, this);
|
||||
ops.push(Op::Conv(Conv { src, src2, cin, cout, k: kh, weight: w.1, bias: b, leaky }));
|
||||
}
|
||||
"ConvTranspose" => {
|
||||
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
|
||||
let (cin, cout, kh, kw) = four(&w.0)?;
|
||||
let b = if b.is_empty() { vec![0.0; cout] } else { b };
|
||||
if (kh, kw) != (2, 2)
|
||||
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|
||||
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "group").is_some_and(|a| a.i != 1)
|
||||
|| attr(node, "output_padding").is_some_and(|a| a.ints.iter().any(|&v| v != 0))
|
||||
{
|
||||
return unsupported("a transposed convolution other than 2 × 2 with stride 2");
|
||||
}
|
||||
if attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
|
||||
return unsupported("transposed convolution auto padding");
|
||||
}
|
||||
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [2, 2]) {
|
||||
return invalid("transposed kernel shape does not match the weights");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("ConvTranspose without an output".into()))?;
|
||||
let (leaky, out_name) = fuse_leaky(out)?;
|
||||
ids.insert(out_name, this);
|
||||
ops.push(Op::ConvT2(ConvT2 { src, cin, cout, weight: w.1, bias: b, leaky }));
|
||||
}
|
||||
"MaxPool" => {
|
||||
if ints_or(node, "kernel_shape", &[]) != [2, 2]
|
||||
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|
||||
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "ceil_mode").is_some_and(|a| a.i != 0)
|
||||
|| attr(node, "storage_order").is_some_and(|a| a.i != 0)
|
||||
|| attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET")
|
||||
{
|
||||
return unsupported("a max-pool other than 2 × 2 with stride 2");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("MaxPool without an output".into()))?;
|
||||
ids.insert(out.clone(), this);
|
||||
ops.push(Op::MaxPool2 { src });
|
||||
}
|
||||
"DepthToSpace" => {
|
||||
if attr(node, "blocksize").map(|a| a.i) != Some(2) || attr(node, "mode").map(|a| a.s.as_slice()) != Some(&b"CRD"[..]) {
|
||||
return unsupported("depth-to-space other than block size 2 in CRD order");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("DepthToSpace without an output".into()))?;
|
||||
ids.insert(out.clone(), this);
|
||||
ops.push(Op::DepthToSpace2 { src });
|
||||
}
|
||||
other => return unsupported(format!("the `{other}` operator")),
|
||||
}
|
||||
}
|
||||
if !joins.is_empty() {
|
||||
return unsupported("a Concat whose result no convolution reads");
|
||||
}
|
||||
if ids.get(output.name.as_str()) != Some(&ops.len()) {
|
||||
return unsupported("the output is not the last layer");
|
||||
}
|
||||
Net::new(in_channels, ops)
|
||||
}
|
||||
|
||||
fn attr<'a>(n: &'a NodeProto, name: &str) -> Option<&'a AttributeProto> {
|
||||
n.attribute.iter().find(|a| a.name == name)
|
||||
}
|
||||
|
||||
/// The integer list attribute `name`, or `default` when the node does not have it.
|
||||
fn ints_or(n: &NodeProto, name: &str, default: &[i64]) -> Vec<i64> {
|
||||
attr(n, name).map_or_else(|| default.to_vec(), |a| a.ints.clone())
|
||||
}
|
||||
|
||||
fn four(dims: &[i64]) -> Result<(usize, usize, usize, usize), NetError> {
|
||||
match dims {
|
||||
[a, b, c, d] if dims.iter().all(|&v| v > 0 && v <= 1 << 20) => Ok((*a as usize, *b as usize, *c as usize, *d as usize)),
|
||||
_ => invalid("a weight tensor that is not four-dimensional"),
|
||||
}
|
||||
}
|
||||
|
||||
type Weights = ((Vec<i64>, Vec<f32>), Vec<f32>);
|
||||
|
||||
/// The weight and bias initialisers of a convolution node.
|
||||
fn weights_of(node: &NodeProto, initialisers: &HashMap<&str, &TensorProto>, total: &mut usize) -> Result<Weights, NetError> {
|
||||
let mut get = |idx: usize| -> Result<(Vec<i64>, Vec<f32>), NetError> {
|
||||
let name = node.input.get(idx).ok_or_else(|| NetError::Invalid(format!("`{}` without its weights", node.op_type)))?;
|
||||
let t = initialisers.get(name.as_str()).ok_or_else(|| NetError::Unsupported(format!("weights that are computed (`{name}`)")))?;
|
||||
let values = floats(t)?;
|
||||
*total = total.saturating_add(values.len());
|
||||
if *total > MAX_WEIGHTS {
|
||||
return invalid("the network has too many weights");
|
||||
}
|
||||
Ok((t.dims.clone(), values))
|
||||
};
|
||||
let w = get(1)?;
|
||||
let b = if node.input.len() > 2 {
|
||||
let (dims, values) = get(2)?;
|
||||
if dims.len() != 1 || values.is_empty() {
|
||||
return invalid("a bias tensor must be a nonempty vector");
|
||||
}
|
||||
values
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((w, b))
|
||||
}
|
||||
|
||||
fn floats(t: &TensorProto) -> Result<Vec<f32>, NetError> {
|
||||
if t.data_type != 1 {
|
||||
return unsupported("weights that are not 32-bit floats");
|
||||
}
|
||||
let want: usize = t
|
||||
.dims
|
||||
.iter()
|
||||
.try_fold(1usize, |n, &d| usize::try_from(d).ok().and_then(|d| n.checked_mul(d)))
|
||||
.ok_or_else(|| NetError::Invalid("a tensor with a negative or huge size".into()))?;
|
||||
if want > MAX_WEIGHTS {
|
||||
return invalid("a tensor with too many numbers");
|
||||
}
|
||||
if !t.raw_data.is_empty() && (!t.float_data.is_empty() || !t.raw_data.len().is_multiple_of(4)) {
|
||||
return invalid("ambiguous or truncated tensor data");
|
||||
}
|
||||
let actual = if t.raw_data.is_empty() { t.float_data.len() } else { t.raw_data.len() / 4 };
|
||||
if actual != want {
|
||||
return invalid("tensor data does not match its dimensions");
|
||||
}
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(want).map_err(|_| NetError::Invalid("not enough memory for weights".into()))?;
|
||||
if !t.raw_data.is_empty() {
|
||||
values.extend(t.raw_data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)));
|
||||
} else {
|
||||
values.extend_from_slice(&t.float_data);
|
||||
}
|
||||
if values.len() != want {
|
||||
return invalid(format!("tensor `{}` has {} numbers, not the {want} its shape says", t.name, values.len()));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// The channel count in an input's declared shape `[batch, channels, …]`.
|
||||
fn channel_dim(v: &ValueInfoProto) -> Option<usize> {
|
||||
v.channels
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::synthetic::unet_onnx;
|
||||
|
||||
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("lc-denoise-onnx-{name}-{}.onnx", std::process::id()));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u_net_is_read_with_its_layers_fused() {
|
||||
let (tile, depth) = (32usize, 2usize);
|
||||
let p = temp_model("unet", &unet_onnx(tile as u64, 8, depth, 7));
|
||||
let net = read(&p).unwrap();
|
||||
// per level two convolutions; a pool per level but the last; going up a transposed convolution and two convolutions
|
||||
// per level (the join is part of the first); then the 1 × 1 convolution and depth-to-space
|
||||
assert_eq!(net.ops().len(), 2 * (depth + 1) + depth + depth * 3 + 2);
|
||||
assert!(matches!(net.ops().last(), Some(crate::net::Op::DepthToSpace2 { .. })));
|
||||
assert!(net.ops().iter().any(|o| matches!(o, crate::net::Op::Conv(c) if c.src2.is_some())), "the joins became two-source convolutions");
|
||||
assert!(
|
||||
net.ops().iter().all(|o| !matches!(o, crate::net::Op::Conv(c) if c.leaky.is_none() && c.k == 3)),
|
||||
"every 3 × 3 convolution has its activation"
|
||||
);
|
||||
assert_eq!((net.in_channels, net.out_channels(), net.depth()), (4, 3, 2));
|
||||
assert!(net.fits_tile(tile) && !net.fits_tile(30));
|
||||
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[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() };
|
||||
let n = NodeProto { input: vec!["data".into(), "w".into(), "b".into()], ..Default::default() };
|
||||
for dims in [vec![0], vec![3, 4]] {
|
||||
let b = TensorProto {
|
||||
name: "b".into(),
|
||||
float_data: vec![0.0; dims.iter().product::<i64>() as usize],
|
||||
dims,
|
||||
data_type: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let inits = HashMap::from([("w", &w), ("b", &b)]);
|
||||
assert!(weights_of(&n, &inits, &mut 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_mutated_protobuf_is_bounded_and_never_panics() {
|
||||
let good = unet_onnx(4, 3, 1, 7);
|
||||
for n in (0..good.len()).step_by(31) {
|
||||
let _ = read_bytes(&good[..n]);
|
||||
}
|
||||
for at in (0..good.len()).step_by(37) {
|
||||
let mut bad = good.clone();
|
||||
bad[at] ^= 0xff;
|
||||
let _ = read_bytes(&bad);
|
||||
}
|
||||
assert!(read_bytes(&[0x3a, 0xff, 0xff, 0xff, 0xff, 0x7f]).is_err());
|
||||
assert!(read_bytes(&[0x3a, 0, 0x3a, 0]).is_err());
|
||||
}
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn arbitrary_model_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..4096)) {
|
||||
let _ = read_bytes(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_description_cannot_say_is_unsupported_and_garbage_is_an_error() {
|
||||
// not a model
|
||||
let p = temp_model("junk", b"this is not an onnx file");
|
||||
assert!(matches!(read(&p), Err(NetError::Invalid(_)) | Err(NetError::Unsupported(_))));
|
||||
let _ = std::fs::remove_file(p);
|
||||
// a missing file
|
||||
assert!(read(Path::new("/does/not/exist.onnx")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Bounded, borrowed decoding of the ONNX fields used by denoise, from the public Apache-2.0 schema:
|
||||
//! https://github.com/onnx/onnx/blob/main/onnx/onnx.proto3
|
||||
//! No generated code, protoc, recursive graph decoding, or external tensor files.
|
||||
|
||||
use crate::net::{MAX_OPS, MAX_WEIGHTS, NetError};
|
||||
|
||||
const MAX_ITEMS: usize = MAX_OPS * 4;
|
||||
const MAX_STRING: usize = 4096;
|
||||
pub(super) const MAX_BYTES: usize = MAX_WEIGHTS * 4 + 16 * 1024 * 1024;
|
||||
|
||||
type Result<T> = std::result::Result<T, NetError>;
|
||||
fn bad() -> NetError {
|
||||
NetError::Invalid("malformed or oversized ONNX protobuf".into())
|
||||
}
|
||||
|
||||
struct Fields<'a> {
|
||||
rest: &'a [u8],
|
||||
count: usize,
|
||||
}
|
||||
enum Wire<'a> {
|
||||
Int(u64),
|
||||
Bytes(&'a [u8]),
|
||||
F32(f32),
|
||||
Other,
|
||||
}
|
||||
|
||||
fn varint(rest: &mut &[u8]) -> Result<u64> {
|
||||
let mut value = 0u64;
|
||||
for shift in (0..70).step_by(7) {
|
||||
let (b, tail) = rest.split_first().ok_or_else(bad)?;
|
||||
*rest = tail;
|
||||
if shift == 63 && *b > 1 {
|
||||
return Err(bad());
|
||||
}
|
||||
value |= u64::from(*b & 127) << shift;
|
||||
if b & 128 == 0 {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(bad())
|
||||
}
|
||||
|
||||
impl<'a> Fields<'a> {
|
||||
fn new(rest: &'a [u8]) -> Self {
|
||||
Self { rest, count: 0 }
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
|
||||
let out = self.rest.get(..n).ok_or_else(bad)?;
|
||||
self.rest = self.rest.get(n..).ok_or_else(bad)?;
|
||||
Ok(out)
|
||||
}
|
||||
fn next(&mut self) -> Result<Option<(u32, Wire<'a>)>> {
|
||||
if self.rest.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.count += 1;
|
||||
if self.count > MAX_WEIGHTS {
|
||||
return Err(bad());
|
||||
}
|
||||
let tag = varint(&mut self.rest)?;
|
||||
let number = u32::try_from(tag >> 3).map_err(|_| bad())?;
|
||||
if number == 0 || number >= 1 << 29 {
|
||||
return Err(bad());
|
||||
}
|
||||
let wire = match tag & 7 {
|
||||
0 => Wire::Int(varint(&mut self.rest)?),
|
||||
1 => {
|
||||
self.take(8)?;
|
||||
Wire::Other
|
||||
}
|
||||
2 => {
|
||||
let n = usize::try_from(varint(&mut self.rest)?).map_err(|_| bad())?;
|
||||
Wire::Bytes(self.take(n)?)
|
||||
}
|
||||
5 => {
|
||||
let b: [u8; 4] = self.take(4)?.try_into().map_err(|_| bad())?;
|
||||
Wire::F32(f32::from_le_bytes(b))
|
||||
}
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
Ok(Some((number, wire)))
|
||||
}
|
||||
}
|
||||
|
||||
fn string(b: &[u8]) -> Result<String> {
|
||||
if b.len() > MAX_STRING {
|
||||
return Err(bad());
|
||||
}
|
||||
std::str::from_utf8(b).map(str::to_owned).map_err(|_| bad())
|
||||
}
|
||||
fn push<T>(v: &mut Vec<T>, value: T, cap: usize) -> Result<()> {
|
||||
if v.len() >= cap {
|
||||
return Err(bad());
|
||||
}
|
||||
v.try_reserve(1).map_err(|_| bad())?;
|
||||
v.push(value);
|
||||
Ok(())
|
||||
}
|
||||
fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
|
||||
match w {
|
||||
Wire::Int(v) => push(out, v as i64, cap),
|
||||
Wire::Bytes(mut b) => {
|
||||
while !b.is_empty() {
|
||||
let v = varint(&mut b)?;
|
||||
push(out, v as i64, cap)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AttributeProto {
|
||||
pub name: String,
|
||||
pub f: f32,
|
||||
pub i: i64,
|
||||
pub s: Vec<u8>,
|
||||
pub ints: Vec<i64>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct NodeProto {
|
||||
pub input: Vec<String>,
|
||||
pub output: Vec<String>,
|
||||
pub op_type: String,
|
||||
pub attribute: Vec<AttributeProto>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct TensorProto<'a> {
|
||||
pub name: String,
|
||||
pub dims: Vec<i64>,
|
||||
pub data_type: i32,
|
||||
pub raw_data: &'a [u8],
|
||||
pub float_data: Vec<f32>,
|
||||
}
|
||||
pub(super) struct ValueInfoProto {
|
||||
pub name: String,
|
||||
pub channels: Option<usize>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Graph<'a> {
|
||||
pub node: Vec<NodeProto>,
|
||||
pub initializer: Vec<TensorProto<'a>>,
|
||||
pub input: Vec<ValueInfoProto>,
|
||||
pub output: Vec<ValueInfoProto>,
|
||||
}
|
||||
|
||||
fn attribute(b: &[u8]) -> Result<AttributeProto> {
|
||||
let mut a = AttributeProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
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,
|
||||
(3, Wire::Int(v)) => a.i = v as i64,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_STRING {
|
||||
return Err(bad());
|
||||
}
|
||||
a.s = b.to_vec();
|
||||
}
|
||||
(8, w) => ints(w, &mut a.ints, 16)?,
|
||||
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
|
||||
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn node(b: &[u8]) -> Result<NodeProto> {
|
||||
let mut a = NodeProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => push(&mut a.input, string(b)?, 4)?,
|
||||
(2, Wire::Bytes(b)) => push(&mut a.output, string(b)?, 2)?,
|
||||
(4, Wire::Bytes(b)) => a.op_type = string(b)?,
|
||||
(5, Wire::Bytes(b)) => push(&mut a.attribute, attribute(b)?, 16)?,
|
||||
(7, Wire::Bytes(b)) if !b.is_empty() => return Err(NetError::Unsupported("a custom operator domain".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn tensor(b: &[u8]) -> Result<TensorProto<'_>> {
|
||||
let mut a = TensorProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, w) => ints(w, &mut a.dims, 8)?,
|
||||
(2, Wire::Int(v)) => a.data_type = i32::try_from(v).map_err(|_| bad())?,
|
||||
(4, Wire::F32(v)) => push(&mut a.float_data, v, MAX_WEIGHTS)?,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if !b.len().is_multiple_of(4) {
|
||||
return Err(bad());
|
||||
}
|
||||
for chunk in b.as_chunks::<4>().0 {
|
||||
push(&mut a.float_data, f32::from_le_bytes(*chunk), MAX_WEIGHTS)?;
|
||||
}
|
||||
}
|
||||
(8, Wire::Bytes(b)) => a.name = string(b)?,
|
||||
(9, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_WEIGHTS * 4 {
|
||||
return Err(bad());
|
||||
}
|
||||
a.raw_data = b;
|
||||
}
|
||||
(13, _) | (14, Wire::Int(1..)) => return Err(NetError::Unsupported("external tensor data".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn channel_shape(b: &[u8]) -> Result<Option<usize>> {
|
||||
let mut fields = Fields::new(b);
|
||||
let mut dim = 0;
|
||||
let mut channels = None;
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (1, Wire::Bytes(b)) = (n, w) {
|
||||
if dim >= 8 {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut df = Fields::new(b);
|
||||
while let Some((n, w)) = df.next()? {
|
||||
if let (1, Wire::Int(v)) = (n, w)
|
||||
&& dim == 1
|
||||
{
|
||||
channels = usize::try_from(v).ok().filter(|&v| v > 0);
|
||||
}
|
||||
}
|
||||
dim += 1;
|
||||
}
|
||||
}
|
||||
Ok(channels)
|
||||
}
|
||||
fn channel_type(b: &[u8]) -> Result<Option<usize>> {
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (1, Wire::Bytes(b)) = (n, w) {
|
||||
let mut tf = Fields::new(b);
|
||||
let (mut elem, mut channels) = (0, None);
|
||||
while let Some((n, w)) = tf.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Int(v)) => elem = v,
|
||||
(2, Wire::Bytes(b)) => channels = channel_shape(b)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return Ok(if elem == 1 { channels } else { None });
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn value_info(b: &[u8]) -> Result<ValueInfoProto> {
|
||||
let mut out = ValueInfoProto { name: String::new(), channels: None };
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => out.name = string(b)?,
|
||||
(2, Wire::Bytes(b)) => out.channels = channel_type(b)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn graph(b: &[u8]) -> Result<Graph<'_>> {
|
||||
let mut out = Graph::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => push(&mut out.node, node(b)?, MAX_ITEMS)?,
|
||||
(5, Wire::Bytes(b)) => push(&mut out.initializer, tensor(b)?, MAX_ITEMS)?,
|
||||
(11, Wire::Bytes(b)) => push(&mut out.input, value_info(b)?, MAX_ITEMS)?,
|
||||
(12, Wire::Bytes(b)) => push(&mut out.output, value_info(b)?, 1)?,
|
||||
(15, _) => return Err(NetError::Unsupported("sparse initializers".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
pub(super) fn decode(b: &[u8]) -> Result<Graph<'_>> {
|
||||
if b.len() > MAX_BYTES {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut fields = Fields::new(b);
|
||||
let mut out = None;
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (7, Wire::Bytes(b)) = (n, w) {
|
||||
if out.is_some() {
|
||||
return Err(bad());
|
||||
}
|
||||
out = Some(graph(b)?);
|
||||
}
|
||||
}
|
||||
out.ok_or_else(bad)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! A plain-loop interpreter of a [`Net`], slow and obviously right: the reference the parser and the GPU runner are
|
||||
//! tested against (tract's answer on the real model is the other). It is not used on photos.
|
||||
|
||||
use crate::net::{Net, NetError, Op, TensorId};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// A tensor in `[channels][side][side]` order.
|
||||
struct T {
|
||||
channels: usize,
|
||||
side: usize,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
|
||||
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
|
||||
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
|
||||
return Err(NetError::Invalid("the tile does not fit the network".into()));
|
||||
}
|
||||
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
|
||||
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
|
||||
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
|
||||
};
|
||||
for op in net.ops() {
|
||||
let out = match op {
|
||||
Op::Conv(c) => {
|
||||
let a = get(&tensors, c.src)?;
|
||||
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
|
||||
let side = tensors[a].side;
|
||||
let pad = (c.k - 1) / 2;
|
||||
let mut data = vec![0f32; c.cout * side * side];
|
||||
// the input channels in order: the first source's, then the second's
|
||||
let planes: Vec<&[f32]> =
|
||||
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
let mut sum = c.bias[co];
|
||||
for (ci, plane) in planes.iter().enumerate() {
|
||||
for ky in 0..c.k {
|
||||
for kx in 0..c.k {
|
||||
let (yy, xx) = ((y + ky) as isize - pad as isize, (x + kx) as isize - pad as isize);
|
||||
if yy < 0 || xx < 0 || yy >= side as isize || xx >= side as isize {
|
||||
continue;
|
||||
}
|
||||
sum += plane[yy as usize * side + xx as usize] * c.weight[((co * c.cin + ci) * c.k + ky) * c.k + kx];
|
||||
}
|
||||
}
|
||||
}
|
||||
data[(co * side + y) * side + x] = leaky(sum, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels: c.cout, side, data }
|
||||
}
|
||||
Op::ConvT2(c) => {
|
||||
let a = get(&tensors, c.src)?;
|
||||
let side = tensors[a].side;
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; c.cout * out_side * out_side];
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let mut sum = c.bias[co];
|
||||
for ci in 0..c.cin {
|
||||
sum += tensors[a].data[(ci * side + y) * side + x] * c.weight[((ci * c.cout + co) * 2 + dy) * 2 + dx];
|
||||
}
|
||||
data[(co * out_side + 2 * y + dy) * out_side + 2 * x + dx] = leaky(sum, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels: c.cout, side: out_side, data }
|
||||
}
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
|
||||
let mut data = vec![0f32; channels * side * side];
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
let at = |dy: usize, dx: usize| tensors[a].data[(ch * 2 * side + 2 * y + dy) * 2 * side + 2 * x + dx];
|
||||
data[(ch * side + y) * side + x] = at(0, 0).max(at(0, 1)).max(at(1, 0)).max(at(1, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels, side, data }
|
||||
}
|
||||
Op::DepthToSpace2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; channels * out_side * out_side];
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
data[(ch * out_side + 2 * y + dy) * out_side + 2 * x + dx] =
|
||||
tensors[a].data[((4 * ch + 2 * dy + dx) * side + y) * side + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels, side: out_side, data }
|
||||
}
|
||||
};
|
||||
tensors.push(out);
|
||||
}
|
||||
tensors.pop().map(|t| t.data).ok_or_else(|| NetError::Invalid("no layers".into()))
|
||||
}
|
||||
|
||||
fn leaky(v: f32, alpha: Option<f32>) -> f32 {
|
||||
match alpha {
|
||||
Some(a) if v < 0.0 => v * a,
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference as a [`TileRunner`] (for tests of the code around the model).
|
||||
pub struct ReferenceRunner {
|
||||
pub net: Net,
|
||||
pub tile: usize,
|
||||
}
|
||||
|
||||
impl TileRunner for ReferenceRunner {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Model loading, a shared installation self-test, and deterministic test tiles.
|
||||
//! `CpuRunner` is the pure-Rust executor; scalar operator tests live in `reference`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::manifest::{DenoiserManifest, Gain};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// What `self_test` found.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelfTest {
|
||||
pub ok: bool,
|
||||
/// Milliseconds to load and optimise the model.
|
||||
pub load_ms: f64,
|
||||
/// Median milliseconds to run one tile.
|
||||
pub tile_ms: f64,
|
||||
/// Megapixels of picture one tile covers, for estimating a whole photo's time.
|
||||
pub tile_megapixels: f64,
|
||||
/// The model's output scale relative to its input on the test picture (what `Gain::MatchMean` takes out).
|
||||
pub scale: f64,
|
||||
/// Each check: what was checked and whether it held.
|
||||
pub checks: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
/// The default pure-Rust CPU model, with the same manifest and installation self-test as the reference.
|
||||
#[derive(Clone)]
|
||||
pub struct CpuRunner {
|
||||
runner: crate::cpu::NetRunner,
|
||||
manifest: DenoiserManifest,
|
||||
load_ms: f64,
|
||||
}
|
||||
impl CpuRunner {
|
||||
pub fn load(path: &Path, manifest: &DenoiserManifest) -> Result<Self, Error> {
|
||||
crate::manifest::validate(manifest).map_err(|e| Error::Load(e.to_string()))?;
|
||||
let started = Instant::now();
|
||||
let net = crate::onnx::read(path).map_err(|e| Error::Load(e.to_string()))?;
|
||||
let runner = crate::cpu::NetRunner::new(&net, manifest.tile as usize).map_err(|e| Error::Load(e.to_string()))?;
|
||||
Ok(Self { runner, manifest: manifest.clone(), load_ms: started.elapsed().as_secs_f64() * 1e3 })
|
||||
}
|
||||
pub fn manifest(&self) -> &DenoiserManifest {
|
||||
&self.manifest
|
||||
}
|
||||
pub fn net(&self) -> &crate::net::Net {
|
||||
self.runner.net()
|
||||
}
|
||||
pub fn bytes_per_tile(&self) -> usize {
|
||||
self.runner.bytes_per_tile()
|
||||
}
|
||||
pub fn self_test(&self) -> SelfTest {
|
||||
self_test(self, &self.manifest, self.load_ms)
|
||||
}
|
||||
}
|
||||
impl TileRunner for CpuRunner {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
self.runner.run(input)
|
||||
}
|
||||
}
|
||||
fn self_test(runner: &dyn TileRunner, manifest: &DenoiserManifest, load_ms: f64) -> SelfTest {
|
||||
let t = manifest.tile as usize;
|
||||
let mut checks: Vec<(String, bool)> = Vec::new();
|
||||
let (input, base) = test_tile(t);
|
||||
let (mut times, mut scale) = (Vec::new(), 0.0f64);
|
||||
let mut run = || {
|
||||
let started = Instant::now();
|
||||
let r = runner.run(&input);
|
||||
times.push(started.elapsed().as_secs_f64() * 1000.0);
|
||||
r
|
||||
};
|
||||
let (a, b) = (run(), run());
|
||||
match (&a, &b) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
checks.push((format!("gives {} finite numbers per tile", a.len()), true));
|
||||
checks.push((
|
||||
"gives the same answer for the same tile".into(),
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4 * x.abs().max(y.abs()).max(1e-9)),
|
||||
));
|
||||
let side = 2 * t;
|
||||
let mean_in = base.iter().map(|&v| f64::from(v)).sum::<f64>() / base.len().max(1) as f64;
|
||||
let mean_out = a.iter().map(|&v| f64::from(v)).sum::<f64>() / a.len().max(1) as f64;
|
||||
scale = if mean_in > 0.0 { mean_out / mean_in } else { 0.0 };
|
||||
if let Gain::MatchMean { nominal, max_deviation } = manifest.gain {
|
||||
let ratio = scale / f64::from(nominal);
|
||||
let tolerance = f64::from(max_deviation).max(0.05) * 4.0;
|
||||
checks.push((format!("its scale is the {nominal:.3e} the description says (found {scale:.3e})"), (ratio - 1.0).abs() <= tolerance));
|
||||
}
|
||||
// noise left after bringing the answer back to the input's scale, against the noise that went in
|
||||
let g = if scale > 0.0 { 1.0 / scale } else { 1.0 };
|
||||
let (mut noise_in, mut noise_out, mut n_in, mut n_out) = (0.0f64, 0.0f64, 0usize, 0usize);
|
||||
for (i, (&v, &s)) in input.iter().zip(base.iter().cycle()).enumerate() {
|
||||
// centre of each plane only: edges are the model's guess
|
||||
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
|
||||
if x >= t / 4 && x < 3 * t / 4 && y >= t / 4 && y < 3 * t / 4 {
|
||||
noise_in += f64::from(v - s).powi(2);
|
||||
n_in += 1;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
for y in side / 4..3 * side / 4 {
|
||||
for x in side / 4..3 * side / 4 {
|
||||
if let (Some(&o), Some(&s)) = (a.get(c * side * side + y * side + x), base.get((y / 2) * t + x / 2)) {
|
||||
noise_out += (f64::from(o) * g - f64::from(s)).powi(2);
|
||||
n_out += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (rms_in, rms_out) = ((noise_in / n_in.max(1) as f64).sqrt(), (noise_out / n_out.max(1) as f64).sqrt());
|
||||
checks.push((format!("takes noise down (from {rms_in:.4} to {rms_out:.4})"), rms_out < 0.8 * rms_in));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => checks.push((format!("runs: {e}"), false)),
|
||||
}
|
||||
times.sort_by(|x, y| x.total_cmp(y));
|
||||
SelfTest {
|
||||
ok: !checks.is_empty() && checks.iter().all(|(_, ok)| *ok),
|
||||
load_ms,
|
||||
tile_ms: times.get(times.len() / 2).copied().unwrap_or(0.0),
|
||||
tile_megapixels: (2 * t * 2 * t) as f64 / 1e6,
|
||||
scale,
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic test tile: four planes of a smooth gradient with noise on top, and the gradient alone for one
|
||||
/// plane (`tile × tile`).
|
||||
pub fn test_tile(t: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
if t == 0 || t > 2048 {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let mut seed = 0x2545_f491u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
let base: Vec<f32> = (0..t * t).map(|i| 0.12 + 0.3 * (((i % t) + (i / t)) as f32 / (2 * t) as f32)).collect();
|
||||
let input = (0..4 * t * t)
|
||||
.map(|i| {
|
||||
// sum of four uniforms: close to Gaussian, deviation 0.0577 × 2 before scaling
|
||||
let n = next() + next() + next() + next() - 2.0;
|
||||
base.get(i % (t * t)).copied().unwrap_or(0.0) + 0.05 * n
|
||||
})
|
||||
.collect();
|
||||
(input, base)
|
||||
}
|
||||
|
||||
/// A tile for checking that two runners of one model agree: the whole range a raw photo has rather than the test tile's
|
||||
/// mid-greys — a noisy ramp from black to white across the tile, a flat black corner and a clipped white one (four
|
||||
/// planes of `tile × tile`).
|
||||
pub fn check_tile(t: usize) -> Vec<f32> {
|
||||
if t == 0 || t > 2048 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
let (edge, span) = ((t / 8).max(1), (2 * t).saturating_sub(2).max(1) as f32);
|
||||
(0..4 * t * t)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
|
||||
let n = next() + next() + next() + next() - 2.0;
|
||||
if x < edge && y < edge {
|
||||
0.0
|
||||
} else if x + edge >= t && y + edge >= t {
|
||||
1.0
|
||||
} else {
|
||||
((x + y) as f32 / span + 0.05 * n).clamp(0.0, 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::licence::Licence;
|
||||
use crate::manifest::Domain;
|
||||
use crate::synthetic_proto::{len_field, value_info_bytes};
|
||||
|
||||
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
fn node(op: &str, inputs: &[&str], output: &str, ints: &[(&str, i64)]) -> Vec<u8> {
|
||||
let mut n = Vec::new();
|
||||
for i in inputs {
|
||||
len_field(1, i.as_bytes(), &mut n);
|
||||
}
|
||||
len_field(2, output.as_bytes(), &mut n);
|
||||
len_field(4, op.as_bytes(), &mut n);
|
||||
for (name, value) in ints {
|
||||
let mut a = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut a);
|
||||
varint_field(3, *value as u64, &mut a);
|
||||
varint_field(20, 2, &mut a);
|
||||
len_field(5, &a, &mut n);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for d in dims {
|
||||
varint_field(1, *d, &mut t);
|
||||
}
|
||||
varint_field(2, 1, &mut t);
|
||||
len_field(8, name.as_bytes(), &mut t);
|
||||
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
len_field(9, &raw, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// A tiny working denoiser-shaped network: `[1, 4, t, t]` → a 1 × 1 convolution to 12 channels (each output
|
||||
/// channel is `scale` times one input plane, so the scale is known) → depth-to-space → `[1, 3, 2t, 2t]`.
|
||||
fn tiny_model(t: u64, scale: f32) -> Vec<u8> {
|
||||
tiny_model_with(t, scale, 12)
|
||||
}
|
||||
|
||||
/// The same with another number of convolution channels; only 12 can be arranged into the three colours.
|
||||
fn tiny_model_with(t: u64, scale: f32, channels: u64) -> Vec<u8> {
|
||||
let mut graph = Vec::new();
|
||||
len_field(1, &node("Conv", &["data", "w"], if channels == 12 { "c" } else { "rgb" }, &[("group", 1)]), &mut graph);
|
||||
if channels == 12 {
|
||||
let mut depth_to_space = node("DepthToSpace", &["c"], "rgb", &[("blocksize", 2)]);
|
||||
let mut mode = Vec::new();
|
||||
len_field(1, b"mode", &mut mode);
|
||||
len_field(4, b"CRD", &mut mode);
|
||||
varint_field(20, 3, &mut mode);
|
||||
len_field(5, &mode, &mut depth_to_space);
|
||||
len_field(1, &depth_to_space, &mut graph);
|
||||
}
|
||||
// CRD output channel colour·4 + dy·2 + dx reads input plane [R, G1, B] of the tile.
|
||||
let mut w = vec![0f32; channels as usize * 4];
|
||||
for oc in 0..channels as usize {
|
||||
let colour = (oc / 4).min(2);
|
||||
let plane = [0, 1, 3][colour];
|
||||
w[oc * 4 + plane] = scale;
|
||||
}
|
||||
len_field(5, &tensor("w", &[channels, 4, 1, 1], &w), &mut graph);
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(t), Ok(t)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * t), Ok(2 * t)]), &mut graph);
|
||||
let mut ops = Vec::new();
|
||||
len_field(1, b"", &mut ops);
|
||||
varint_field(2, 13, &mut ops);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
||||
fn manifest(tile: u32, nominal: f32) -> DenoiserManifest {
|
||||
DenoiserManifest {
|
||||
id: "tiny".into(),
|
||||
name: "Tiny".into(),
|
||||
version: "1".into(),
|
||||
licence: Licence::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile,
|
||||
overlap: 16,
|
||||
gain: Gain::MatchMean { nominal, max_deviation: 0.05 },
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("lc-denoise-runtime-{name}-{}.onnx", std::process::id()));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_model_loads_runs_and_has_the_scale_it_says() {
|
||||
let p = temp_model("ok", &tiny_model(64, 1000.0));
|
||||
let r = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap();
|
||||
let input: Vec<f32> = (0..4 * 64 * 64).map(|i| (i % 97) as f32 / 97.0).collect();
|
||||
let out = r.run(&input).unwrap();
|
||||
assert_eq!(out.len(), 3 * 128 * 128);
|
||||
// output (colour c, pixel (2y+dy, 2x+dx)) = scale × input plane [R, G1, B][c] at (y, x)
|
||||
for (c, plane) in [0usize, 1, 3].into_iter().enumerate() {
|
||||
let (y, x) = (5usize, 9usize);
|
||||
assert!((out[c * 128 * 128 + (2 * y + 1) * 128 + 2 * x] - 1000.0 * input[plane * 64 * 64 + y * 64 + x]).abs() < 1e-2);
|
||||
}
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_self_test_checks_scale_and_noise() {
|
||||
// a model that only multiplies cannot take noise down: it must fail that check but pass the others
|
||||
let p = temp_model("selftest", &tiny_model(64, 1000.0));
|
||||
let t = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap().self_test();
|
||||
assert!(!t.ok, "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("same answer") && *ok), "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("scale") && *ok), "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("noise") && !*ok), "{t:?}");
|
||||
// and one that claims the wrong scale fails that check
|
||||
let q = CpuRunner::load(&p, &manifest(64, 5.0)).unwrap().self_test();
|
||||
assert!(q.checks.iter().any(|(c, ok)| c.contains("scale") && !*ok), "{q:?}");
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_sizes_and_broken_files_are_errors() {
|
||||
let p = temp_model("sizes", &tiny_model(64, 1.0));
|
||||
let r = CpuRunner::load(&p, &manifest(64, 1.0)).unwrap();
|
||||
assert!(matches!(r.run(&[0.0; 10]), Err(Error::Input(_))));
|
||||
// a fully convolutional model works at whatever tile size the manifest asks for
|
||||
let wide = CpuRunner::load(&p, &manifest(128, 1.0)).unwrap();
|
||||
assert_eq!(wide.run(&vec![0.5; 4 * 128 * 128]).unwrap().len(), 3 * 256 * 256);
|
||||
// a model that gives another number of values than the manifest promises is an error
|
||||
let q = temp_model("channels", &tiny_model_with(64, 1.0, 8));
|
||||
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "shape is refused before execution");
|
||||
let _ = std::fs::remove_file(q);
|
||||
for (name, bytes) in [("junk", b"nonsense".to_vec()), ("empty", Vec::new())] {
|
||||
let q = temp_model(name, &bytes);
|
||||
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "{name}");
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
assert!(CpuRunner::load(&std::env::temp_dir().join("does-not-exist.onnx"), &manifest(64, 1.0)).is_err());
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_tile_covers_black_to_white() {
|
||||
for t in [1, 2, 7, 64] {
|
||||
let c = check_tile(t);
|
||||
assert_eq!(c.len(), 4 * t * t);
|
||||
assert!(c.iter().all(|v| (0.0..=1.0).contains(v)), "{t}");
|
||||
}
|
||||
let c = check_tile(64);
|
||||
let near = |lo: f32, hi: f32| c.iter().filter(|v| (lo..=hi).contains(*v)).count();
|
||||
assert!(near(0.0, 0.0) >= 4 * 8 * 8 && near(1.0, 1.0) >= 4 * 8 * 8, "a flat black and a clipped white corner");
|
||||
for band in 0..10 {
|
||||
let lo = band as f32 / 10.0;
|
||||
assert!(near(lo, lo + 0.1) > 100, "values around {lo}");
|
||||
}
|
||||
assert_eq!(check_tile(64), c, "the same tile every time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupted_models_never_panic() {
|
||||
let good = tiny_model(64, 1.0);
|
||||
for n in (0..good.len()).step_by(5) {
|
||||
let q = temp_model("trunc", &good[..n]);
|
||||
let _ = CpuRunner::load(&q, &manifest(64, 1.0));
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
for at in (0..good.len()).step_by(7) {
|
||||
let mut bad = good.clone();
|
||||
if let Some(b) = bad.get_mut(at) {
|
||||
*b = b.wrapping_add(0x55);
|
||||
}
|
||||
let q = temp_model("flip", &bad);
|
||||
if let Ok(r) = CpuRunner::load(&q, &manifest(64, 1.0)) {
|
||||
let _ = r.run(&vec![0.5; 4 * 64 * 64]);
|
||||
}
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: `LC_DENOISE_MODEL=<model_bayer.onnx> cargo test -p lightcraft-denoise --release
|
||||
/// -- --ignored --nocapture` self-tests a real model (RawNIND's Bayer model: tile 512, scale about 1e6).
|
||||
#[test]
|
||||
#[ignore = "needs a real model file: set LC_DENOISE_MODEL"]
|
||||
fn a_real_model_passes_the_self_test() {
|
||||
let Some(path) = std::env::var_os("LC_DENOISE_MODEL") else { return };
|
||||
let m = manifest(512, 1.0e6);
|
||||
let r = CpuRunner::load(Path::new(&path), &m).unwrap();
|
||||
let t = r.self_test();
|
||||
println!("load {:.0} ms | {:.0} ms per tile | scale {:.4e} | {:?}", t.load_ms, t.tile_ms, t.scale, t.checks);
|
||||
assert!(t.ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn manifest_and_test_tile_sizes_are_checked_without_tract() {
|
||||
for t in [0, 2049, usize::MAX] {
|
||||
assert!(test_tile(t).0.is_empty());
|
||||
assert!(check_tile(t).is_empty());
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("lc-cpu-load-{}.onnx", std::process::id()));
|
||||
std::fs::write(&path, crate::synthetic::unet_onnx(64, 5, 1, 7)).unwrap();
|
||||
let mut m = DenoiserManifest {
|
||||
id: "synthetic".into(),
|
||||
name: "Synthetic".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: "synthetic test".into(),
|
||||
domain: crate::manifest::Domain::BayerToRgb,
|
||||
tile: 64,
|
||||
overlap: 16,
|
||||
gain: Gain::None,
|
||||
};
|
||||
let r = CpuRunner::load(&path, &m).unwrap();
|
||||
let x = check_tile(64);
|
||||
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
|
||||
assert!(r.self_test().checks.iter().any(|(text, ok)| text.contains("same answer") && *ok));
|
||||
m.tile = u32::MAX;
|
||||
assert!(CpuRunner::load(&path, &m).is_err());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! A small ONNX U-Net built in memory, for tests of the layers that read and run denoise networks (here, in the GPU
|
||||
//! runner and in the engine): the same layer kinds and wiring as the real denoisers, with deterministic random weights.
|
||||
//! Nothing here is a model anyone would use.
|
||||
|
||||
use crate::synthetic_proto::{len_field, value_info_bytes};
|
||||
|
||||
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
enum A<'a> {
|
||||
Int(i64),
|
||||
Ints(&'a [i64]),
|
||||
Float(f32),
|
||||
Str(&'a str),
|
||||
}
|
||||
|
||||
fn node(op: &str, inputs: &[&str], output: &str, attrs: &[(&str, A)]) -> Vec<u8> {
|
||||
let mut n = Vec::new();
|
||||
for i in inputs {
|
||||
len_field(1, i.as_bytes(), &mut n);
|
||||
}
|
||||
len_field(2, output.as_bytes(), &mut n);
|
||||
len_field(4, op.as_bytes(), &mut n);
|
||||
for (name, value) in attrs {
|
||||
let mut a = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut a);
|
||||
match value {
|
||||
A::Int(v) => {
|
||||
varint_field(3, *v as u64, &mut a);
|
||||
varint_field(20, 2, &mut a);
|
||||
}
|
||||
A::Ints(vs) => {
|
||||
for v in *vs {
|
||||
varint_field(8, *v as u64, &mut a);
|
||||
}
|
||||
varint_field(20, 7, &mut a);
|
||||
}
|
||||
A::Float(v) => {
|
||||
varint((2 << 3) | 5, &mut a);
|
||||
a.extend_from_slice(&v.to_le_bytes());
|
||||
varint_field(20, 1, &mut a);
|
||||
}
|
||||
A::Str(s) => {
|
||||
len_field(4, s.as_bytes(), &mut a);
|
||||
varint_field(20, 3, &mut a);
|
||||
}
|
||||
}
|
||||
len_field(5, &a, &mut n);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for d in dims {
|
||||
varint_field(1, *d, &mut t);
|
||||
}
|
||||
varint_field(2, 1, &mut t);
|
||||
len_field(8, name.as_bytes(), &mut t);
|
||||
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
len_field(9, &raw, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
nodes: Vec<Vec<u8>>,
|
||||
inits: Vec<Vec<u8>>,
|
||||
rng: Rng,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn name(&mut self, kind: &str) -> String {
|
||||
self.n += 1;
|
||||
format!("{kind}{}", self.n)
|
||||
}
|
||||
|
||||
/// Random weights scaled so activations keep a sane size through the layers.
|
||||
fn weights(&mut self, name: &str, dims: &[u64], fan_in: usize) -> String {
|
||||
let count: u64 = dims.iter().product();
|
||||
let scale = (3.0 / fan_in as f32).sqrt();
|
||||
let values: Vec<f32> = (0..count).map(|_| self.rng.next() * scale).collect();
|
||||
self.inits.push(tensor(name, dims, &values));
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
fn bias(&mut self, name: &str, n: u64) -> String {
|
||||
let values: Vec<f32> = (0..n).map(|_| self.rng.next() * 0.1).collect();
|
||||
self.inits.push(tensor(name, &[n], &values));
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// `Conv` (+ `LeakyRelu` when `leaky`); gives the name of the tensor that results.
|
||||
fn conv(&mut self, src: &str, cin: u64, cout: u64, k: u64, leaky: bool) -> String {
|
||||
let (w, b, out) = (self.name("w"), self.name("b"), self.name("conv"));
|
||||
let w = self.weights(&w, &[cout, cin, k, k], (cin * k * k) as usize);
|
||||
let b = self.bias(&b, cout);
|
||||
let pad = (k as i64 - 1) / 2;
|
||||
self.nodes.push(node(
|
||||
"Conv",
|
||||
&[src, &w, &b],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[k as i64, k as i64])), ("pads", A::Ints(&[pad; 4])), ("strides", A::Ints(&[1, 1])), ("group", A::Int(1))],
|
||||
));
|
||||
if !leaky {
|
||||
return out;
|
||||
}
|
||||
let act = self.name("act");
|
||||
self.nodes.push(node("LeakyRelu", &[&out], &act, &[("alpha", A::Float(0.2))]));
|
||||
act
|
||||
}
|
||||
|
||||
fn pool(&mut self, src: &str) -> String {
|
||||
let out = self.name("pool");
|
||||
self.nodes.push(node(
|
||||
"MaxPool",
|
||||
&[src],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn up(&mut self, src: &str, cin: u64, cout: u64) -> String {
|
||||
let (w, b, out) = (self.name("uw"), self.name("ub"), self.name("up"));
|
||||
let w = self.weights(&w, &[cin, cout, 2, 2], (cin * 4) as usize);
|
||||
let b = self.bias(&b, cout);
|
||||
self.nodes.push(node(
|
||||
"ConvTranspose",
|
||||
&[src, &w, &b],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn concat(&mut self, a: &str, b: &str) -> String {
|
||||
let out = self.name("cat");
|
||||
self.nodes.push(node("Concat", &[a, b], &out, &[("axis", A::Int(1))]));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A denoiser-shaped U-Net as ONNX bytes: `[1, 4, tile, tile]` → `[1, 3, 2·tile, 2·tile]`, with `depth` poolings
|
||||
/// (so `tile` must be a multiple of `2^depth`) and `ch` channels on the first level, doubling on each (every count a
|
||||
/// multiple of 4). Each level is two 3 × 3 convolutions with a leaky ReLU, as in the real networks; the output is a
|
||||
/// 1 × 1 convolution to 12 channels and depth-to-space.
|
||||
pub fn unet_onnx(tile: u64, ch: u64, depth: usize, seed: u64) -> Vec<u8> {
|
||||
let mut b = Builder { nodes: Vec::new(), inits: Vec::new(), rng: Rng(seed | 1), n: 0 };
|
||||
let mut skips: Vec<(String, u64)> = Vec::new();
|
||||
let (mut cur, mut c_in, mut c) = ("data".to_string(), 4u64, ch);
|
||||
for level in 0..=depth {
|
||||
let x = b.conv(&cur, c_in, c, 3, true);
|
||||
let x = b.conv(&x, c, c, 3, true);
|
||||
if level < depth {
|
||||
skips.push((x.clone(), c));
|
||||
cur = b.pool(&x);
|
||||
c_in = c;
|
||||
c *= 2;
|
||||
} else {
|
||||
cur = x;
|
||||
}
|
||||
}
|
||||
for (skip, sc) in skips.into_iter().rev() {
|
||||
let up = b.up(&cur, c, sc);
|
||||
let joined = b.concat(&up, &skip);
|
||||
let x = b.conv(&joined, 2 * sc, sc, 3, true);
|
||||
cur = b.conv(&x, sc, sc, 3, true);
|
||||
c = sc;
|
||||
}
|
||||
let rgb = b.conv(&cur, c, 12, 1, false);
|
||||
b.nodes.push(node("DepthToSpace", &[&rgb], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]));
|
||||
|
||||
let mut graph = Vec::new();
|
||||
for n in &b.nodes {
|
||||
len_field(1, n, &mut graph);
|
||||
}
|
||||
for t in &b.inits {
|
||||
len_field(5, t, &mut graph);
|
||||
}
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(tile), Ok(tile)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * tile), Ok(2 * tile)]), &mut graph);
|
||||
let mut ops = Vec::new();
|
||||
len_field(1, b"", &mut ops);
|
||||
varint_field(2, 13, &mut ops);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
||||
/// A tiny smoothing network for installing a real CPU model in setup-flow tests.
|
||||
/// It averages a 3 × 3 neighborhood of the four Bayer planes and returns three identical channels.
|
||||
pub fn smoothing_onnx() -> Vec<u8> {
|
||||
let mut graph = Vec::new();
|
||||
len_field(1, &node("Conv", &["data", "weights"], "samples", &[("kernel_shape", A::Ints(&[3, 3])), ("pads", A::Ints(&[1, 1, 1, 1]))]), &mut graph);
|
||||
len_field(1, &node("DepthToSpace", &["samples"], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]), &mut graph);
|
||||
len_field(5, &tensor("weights", &[12, 4, 3, 3], &[1.0 / 36.0; 12 * 4 * 3 * 3]), &mut graph);
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(64), Ok(64)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(128), Ok(128)]), &mut graph);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
let mut ops = Vec::new();
|
||||
varint_field(2, 13, &mut ops);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
pub(crate) fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len_field(n: u64, body: &[u8], out: &mut Vec<u8>) {
|
||||
varint(n << 3 | 2, out);
|
||||
varint(body.len() as u64, out);
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
|
||||
pub(crate) fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
/// A graph input or output: `Ok(n)` is a fixed dimension, `Err(name)` a named (dynamic) one.
|
||||
pub fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result<u64, &str>]) -> Vec<u8> {
|
||||
let mut shape = Vec::new();
|
||||
for d in dims {
|
||||
let mut dim = Vec::new();
|
||||
match d {
|
||||
Ok(v) => varint_field(1, *v, &mut dim),
|
||||
Err(p) => len_field(2, p.as_bytes(), &mut dim),
|
||||
}
|
||||
len_field(1, &dim, &mut shape);
|
||||
}
|
||||
let mut tensor = Vec::new();
|
||||
varint_field(1, elem, &mut tensor);
|
||||
len_field(2, &shape, &mut tensor);
|
||||
let mut ty = Vec::new();
|
||||
len_field(1, &tensor, &mut ty);
|
||||
let mut vi = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut vi);
|
||||
len_field(2, &ty, &mut vi);
|
||||
vi
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Opt-in end-to-end check of the real RawNIND Bayer model on real mosaics with noise added:
|
||||
//!
|
||||
//! ```text
|
||||
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
|
||||
//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! `LC_DENOISE_MOSAICS` holds `<name>.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white
|
||||
//! balanced), `<name>.rgb.f32` (a clean demosaic of it, camera RGB, interleaved) and `<name>.txt` (`width`,
|
||||
//! `height`, `cfa`, `wb` lines). Noise is Poisson-Gaussian, made here. Optional: `LC_DENOISE_PARALLEL` (tiles at
|
||||
//! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none),
|
||||
//! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop).
|
||||
|
||||
#![cfg(feature = "runtime")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_denoise::bayer::Layout;
|
||||
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
|
||||
use lightcraft_denoise::run::{Control, Params, denoise_bayer};
|
||||
use lightcraft_denoise::runtime::CpuRunner;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
|
||||
}
|
||||
fn gauss(&mut self) -> f64 {
|
||||
(-2.0 * self.next().ln()).sqrt() * (std::f64::consts::TAU * self.next()).cos()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_f32(path: &Path) -> Vec<f32> {
|
||||
std::fs::read(path).unwrap().as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
|
||||
}
|
||||
|
||||
struct Dump {
|
||||
width: usize,
|
||||
height: usize,
|
||||
layout: Layout,
|
||||
wb: [f32; 3],
|
||||
mosaic: Vec<f32>,
|
||||
rgb: Vec<f32>,
|
||||
}
|
||||
|
||||
fn load(dir: &Path, name: &str) -> Dump {
|
||||
let text = std::fs::read_to_string(dir.join(format!("{name}.txt"))).unwrap();
|
||||
let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(k).map(|v| v.trim().to_string())).unwrap();
|
||||
let cell = match field("cfa").as_str() {
|
||||
"RGGB" => [0, 1, 1, 2],
|
||||
"BGGR" => [2, 1, 1, 0],
|
||||
"GRBG" => [1, 0, 2, 1],
|
||||
"GBRG" => [1, 2, 0, 1],
|
||||
other => panic!("cfa {other}"),
|
||||
};
|
||||
let wb: Vec<f32> = field("wb").split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
Dump {
|
||||
width: field("width").parse().unwrap(),
|
||||
height: field("height").parse().unwrap(),
|
||||
layout: Layout::from_cell(cell).unwrap(),
|
||||
wb: [wb[0], wb[1], wb[2]],
|
||||
mosaic: read_f32(&dir.join(format!("{name}.mosaic.f32"))),
|
||||
rgb: read_f32(&dir.join(format!("{name}.rgb.f32"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop with an even origin so the Bayer phase is kept.
|
||||
fn crop(d: &Dump, x0: usize, y0: usize, w: usize, h: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut m = Vec::with_capacity(w * h);
|
||||
let mut c = Vec::with_capacity(w * h * 3);
|
||||
for y in y0..y0 + h {
|
||||
m.extend_from_slice(&d.mosaic[y * d.width + x0..y * d.width + x0 + w]);
|
||||
c.extend_from_slice(&d.rgb[(y * d.width + x0) * 3..(y * d.width + x0 + w) * 3]);
|
||||
}
|
||||
(m, c)
|
||||
}
|
||||
|
||||
/// 0 = red, 1 = green, 2 = blue at a mosaic position.
|
||||
fn site(layout: Layout, x: usize, y: usize) -> usize {
|
||||
match ((x + layout.dx) % 2, (y + layout.dy) % 2) {
|
||||
(0, 0) => 0,
|
||||
(1, 1) => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The simplest demosaic (each missing colour is the mean of the same-coloured samples in the 3 × 3 around),
|
||||
/// as the baseline a user would get from the noisy picture without AI.
|
||||
fn bilinear(m: &[f32], w: usize, h: usize, layout: Layout) -> Vec<f32> {
|
||||
let mut out = vec![0f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut sum = [0f32; 3];
|
||||
let mut n = [0f32; 3];
|
||||
for yy in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for xx in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
let c = site(layout, xx, yy);
|
||||
sum[c] += m[yy * w + xx];
|
||||
n[c] += 1.0;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
out[(y * w + x) * 3 + c] = if n[c] > 0.0 { sum[c] / n[c] } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Signal-to-error ratio in dB of `got` against `want` (both interleaved RGB), ignoring a border.
|
||||
fn snr(got: &[f32], want: &[f32], w: usize, h: usize, border: usize) -> f64 {
|
||||
let (mut sig, mut err) = (0f64, 0f64);
|
||||
for y in border..h - border {
|
||||
for x in border..w - border {
|
||||
for c in 0..3 {
|
||||
let (g, t) = (f64::from(got[(y * w + x) * 3 + c]), f64::from(want[(y * w + x) * 3 + c]));
|
||||
sig += t * t;
|
||||
err += (g - t).powi(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
10.0 * (sig / err.max(1e-30)).log10()
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// A viewable PNG of interleaved camera-RGB panels side by side: white balanced, one exposure for all, gamma.
|
||||
fn write_png(path: &Path, panels: &[&[f32]], w: usize, h: usize, wb: [f32; 3], exposure: f32) {
|
||||
let total_w = w * panels.len();
|
||||
let mut raw = Vec::with_capacity((total_w * 3 + 1) * h);
|
||||
for y in 0..h {
|
||||
raw.push(0u8);
|
||||
for p in panels {
|
||||
for x in 0..w {
|
||||
for c in 0..3 {
|
||||
let v = (p[(y * w + x) * 3 + c] * wb[c] * exposure).clamp(0.0, 1.0);
|
||||
raw.push((v.powf(1.0 / 2.2) * 255.0 + 0.5) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut chunk = |kind: &[u8; 4], body: &[u8]| {
|
||||
png.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
let mut k = kind.to_vec();
|
||||
k.extend_from_slice(body);
|
||||
png.extend_from_slice(&k);
|
||||
png.extend_from_slice(&crc32(&k).to_be_bytes());
|
||||
};
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&(total_w as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
|
||||
chunk(b"IHDR", &ihdr);
|
||||
chunk(b"IDAT", &z);
|
||||
chunk(b"IEND", &[]);
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a real model and mosaics: see the module docs"]
|
||||
fn the_real_model_denoises_real_mosaics() {
|
||||
let (Some(model), Some(dir)) = (std::env::var_os("LC_DENOISE_MODEL"), std::env::var_os("LC_DENOISE_MOSAICS")) else { return };
|
||||
let dir = PathBuf::from(dir);
|
||||
let out_dir = std::env::var_os("LC_DENOISE_OUT").map(PathBuf::from);
|
||||
let parallel: usize = std::env::var("LC_DENOISE_PARALLEL").ok().and_then(|v| v.parse().ok()).unwrap_or(8);
|
||||
let iso: f64 = std::env::var("LC_DENOISE_ISO").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let names: Vec<String> = match std::env::var("LC_DENOISE_ONLY") {
|
||||
Ok(n) => vec![n],
|
||||
Err(_) => ["arw-sony-a7m3-compressed", "cr2-canon-6d", "nef-nikon-d5100-lossless", "dng-google-pixel2xl", "pef-pentax-k3"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
};
|
||||
let manifest = DenoiserManifest {
|
||||
id: "rawnind-bayer".into(),
|
||||
name: "RawNIND Bayer".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
};
|
||||
let runner = CpuRunner::load(Path::new(&model), &manifest).unwrap();
|
||||
let params = Params { tile: 512, overlap: 64, gain: manifest.gain, clip: Some(1.0), parallel };
|
||||
for name in names {
|
||||
let d = load(&dir, &name);
|
||||
// a crop of about 2 × 1.5 tiles, away from the edges, even origin
|
||||
let full = std::env::var_os("LC_DENOISE_FULL").is_some();
|
||||
let (w, h) = if full { (d.width & !1, d.height & !1) } else { (2048usize, 1536usize) };
|
||||
let (x0, y0) = (((d.width - w) / 3) & !1, ((d.height - h) / 2) & !1);
|
||||
let (clean, reference) = crop(&d, x0, y0, w, h);
|
||||
// Poisson-Gaussian noise: variance a·signal + b, as a sensor at high ISO
|
||||
let (a, b) = (1.5e-3 * iso, (4.0e-3 * iso.sqrt()).powi(2));
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ name.len() as u64);
|
||||
let noisy: Vec<f32> = clean
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
let v = f64::from(v);
|
||||
let sigma = (a * v.max(0.0) + b).sqrt();
|
||||
((v + sigma * rng.gauss()) as f32).min(1.0)
|
||||
})
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
let done = std::sync::atomic::AtomicUsize::new(0);
|
||||
let progress = |n: usize, total: usize| done.store(n * 1000 / total, std::sync::atomic::Ordering::Relaxed);
|
||||
let denoised = denoise_bayer(&noisy, w, h, d.layout, &runner, ¶ms, &Control { cancel: None, progress: Some(&progress) }).unwrap();
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
let baseline = bilinear(&noisy, w, h, d.layout);
|
||||
let clean_bilinear = bilinear(&clean, w, h, d.layout);
|
||||
let den: Vec<f32> = denoised.data.iter().flat_map(|p| p.iter().copied()).collect();
|
||||
let (s_noisy, s_den) = (snr(&baseline, &reference, w, h, 64), snr(&den, &reference, w, h, 64));
|
||||
let s_floor = snr(&clean_bilinear, &reference, w, h, 64);
|
||||
println!(
|
||||
"{name}: {w}×{h} crop, {} tiles, parallel {parallel}, {secs:.1} s | SNR vs clean demosaic: noisy bilinear {s_noisy:.2} dB, denoised {s_den:.2} dB (clean bilinear {s_floor:.2} dB)",
|
||||
lightcraft_denoise::run::tile_count(w, h, d.layout, 512, 64)
|
||||
);
|
||||
if let Some(out) = &out_dir {
|
||||
std::fs::create_dir_all(out).unwrap();
|
||||
let mean = reference.iter().map(|&v| f64::from(v)).sum::<f64>() / reference.len() as f64;
|
||||
let exposure = (0.18 / mean.max(1e-6)) as f32;
|
||||
// three panels, a 512 × 384 window each, at 1:1
|
||||
let (pw, ph, px, py) = (512usize, 384usize, 700usize, 500usize);
|
||||
let window =
|
||||
|buf: &[f32]| -> Vec<f32> { (0..ph).flat_map(|y| buf[((py + y) * w + px) * 3..((py + y) * w + px + pw) * 3].to_vec()).collect() };
|
||||
write_png(&out.join(format!("{name}.png")), &[&window(&baseline), &window(&den), &window(&reference)], pw, ph, d.wb, exposure);
|
||||
}
|
||||
if iso >= 4.0 {
|
||||
assert!(s_den > s_noisy + 2.0, "{name}: denoised {s_den:.2} dB is not clearly better than noisy {s_noisy:.2} dB");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,7 @@ controls! {
|
||||
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
|
||||
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
|
||||
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
|
||||
"enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
|
||||
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
|
||||
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;
|
||||
|
||||
@@ -68,6 +68,39 @@ impl DevelopSettings {
|
||||
!self.disabled_sections.iter().any(|s| s == section)
|
||||
}
|
||||
|
||||
/// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
|
||||
/// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
|
||||
/// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
|
||||
/// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
|
||||
pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
|
||||
let off = |section: &str| !self.section_enabled(section);
|
||||
if !(off("light") || off("color") || off("detail")) {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
if off("light") {
|
||||
d.reset_section(Section::Light);
|
||||
d.reset_section(Section::Curve);
|
||||
}
|
||||
if off("color") {
|
||||
for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
|
||||
d.reset_section(section);
|
||||
}
|
||||
d.point_colors.clear();
|
||||
}
|
||||
if off("detail") {
|
||||
d.reset_section(Section::Detail);
|
||||
}
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// How much of the AI-denoised picture to mix in, 0 (none) to 1 (all of it): the Denoise amount, unless the Detail
|
||||
/// section is switched off or the value is not a number.
|
||||
pub fn denoise_amount(&self) -> f32 {
|
||||
let a = self.enhance.denoise;
|
||||
if a.is_finite() && self.enhance.denoise_enabled() && self.section_enabled("detail") { (a / 100.0).clamp(0.0, 1.0) as f32 } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
|
||||
self.disabled_sections.retain(|s| s != section);
|
||||
if !on {
|
||||
@@ -86,6 +119,7 @@ impl DevelopSettings {
|
||||
self.curve = d;
|
||||
}
|
||||
Section::Color => self.wb.mode = WbMode::AsShot,
|
||||
Section::Detail => self.enhance.denoise_on = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -167,4 +201,40 @@ mod tests {
|
||||
s.set_section_enabled("effects", true);
|
||||
assert!(s.section_enabled("effects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_denoise_amount_is_a_slider_and_follows_the_detail_section() {
|
||||
let mut s = DevelopSettings::default();
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
assert!(controls::set(&mut s, "enhance.denoise", 60.0));
|
||||
assert_eq!(controls::get(&s, "enhance.denoise"), Some(60.0));
|
||||
assert!((s.denoise_amount() - 0.6).abs() < 1e-6);
|
||||
// the slider's range is 0 to 100; values outside it are brought in
|
||||
controls::set(&mut s, "enhance.denoise", 500.0);
|
||||
assert_eq!(s.denoise_amount(), 1.0);
|
||||
controls::set(&mut s, "enhance.denoise", -5.0);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
// switching the Detail section off switches the denoise off with it; a settings file with a bad number does nothing
|
||||
controls::set(&mut s, "enhance.denoise", 80.0);
|
||||
s.set_section_enabled("detail", false);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
s.set_section_enabled("detail", true);
|
||||
s.enhance.denoise = f64::NAN;
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
// it is part of Detail: a reset of the section clears it
|
||||
controls::set(&mut s, "enhance.denoise", 40.0);
|
||||
s.reset_section(Section::Detail);
|
||||
assert_eq!(s.enhance.denoise, 0.0);
|
||||
// The later independent switch preserves Amount when off, and can be on at zero.
|
||||
controls::set(&mut s, "enhance.denoise", 75.0);
|
||||
s.enhance.denoise_on = Some(false);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
assert_eq!(s.enhance.denoise, 75.0);
|
||||
s.enhance.denoise_on = Some(true);
|
||||
assert_eq!(s.denoise_amount(), 0.75);
|
||||
controls::set(&mut s, "enhance.denoise", 0.0);
|
||||
assert!(s.enhance.denoise_enabled());
|
||||
s.reset_section(Section::Detail);
|
||||
assert_eq!(s.enhance.denoise_on, None);
|
||||
}
|
||||
}
|
||||
@@ -915,6 +915,14 @@ pub struct LensBlur {
|
||||
#[serde(default)]
|
||||
pub struct Enhance {
|
||||
pub denoise: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub denoise_on: Option<bool>,
|
||||
pub raw_details: bool,
|
||||
pub super_resolution: bool,
|
||||
}
|
||||
|
||||
impl Enhance {
|
||||
pub fn denoise_enabled(&self) -> bool {
|
||||
self.denoise_on.unwrap_or(self.denoise > 0.0)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,10 @@ web-time = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true, optional = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
@@ -32,11 +35,19 @@ log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
unicode-segmentation = "1.13"
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
# model downloads run natively only (the web build fetches no models)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
lightcraft-fetch = { workspace = true }
|
||||
|
||||
[features]
|
||||
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
|
||||
sam = ["dep:lightcraft-segment"]
|
||||
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-gpu/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
libc = { workspace = true }
|
||||
lightcraft-tiff = { workspace = true }
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
|
||||
//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! never output pixels or a replacement for RAW editing.
|
||||
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
|
||||
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
|
||||
@@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192;
|
||||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||||
/// same formats by file extension.
|
||||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2)
|
||||
matches!(
|
||||
format,
|
||||
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
@@ -40,13 +43,13 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
}
|
||||
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// only the tone and chroma curves are fitted per photo (DRO and picture styles vary).
|
||||
// try its colour first and fit tone/chroma per photo (DRO and picture styles vary).
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let look = fit_pairs_with(&sensor, &reference, colour)?;
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
|
||||
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}",
|
||||
raw.format,
|
||||
look.matrix.0,
|
||||
look.tone,
|
||||
@@ -83,14 +86,161 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
}
|
||||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
|
||||
if raw.format == RawFormat::Cr3 {
|
||||
// Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
|
||||
sensor = sensor.into_oriented(raw.orientation);
|
||||
reference = reference.into_oriented(raw.orientation);
|
||||
sensor.map_in_place(to_working);
|
||||
// Canon's JPEG can be cropped differently from the sensor. Estimate only that common
|
||||
// framing from edge directions, independently of the subsequent colour fit.
|
||||
sensor = align_cr3_framing(sensor, &reference);
|
||||
}
|
||||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||||
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
|
||||
if raw.format != RawFormat::Cr3 {
|
||||
sensor.map_in_place(to_working);
|
||||
}
|
||||
Some((sensor, reference))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cr3Framing {
|
||||
scale: f32,
|
||||
/// Translation as a fraction of the oriented frame's width and height.
|
||||
offset: [f32; 2],
|
||||
}
|
||||
|
||||
impl Cr3Framing {
|
||||
const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] };
|
||||
|
||||
fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) {
|
||||
let (w, h) = (w as f32, h as f32);
|
||||
((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different
|
||||
/// white balance and monotone camera tone; no target RGB values train this three-parameter fit.
|
||||
/// A separate third of the edges must confirm a substantial improvement and a close match.
|
||||
/// A weak, flat or unrelated preview leaves the sensor framing unchanged.
|
||||
fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Cr3Framing> {
|
||||
if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
let sensor = fit(sensor, PROXY, PROXY, Filter::Box);
|
||||
let (w, h) = (sensor.width, sensor.height);
|
||||
if w < 16 || h < 16 {
|
||||
return None;
|
||||
}
|
||||
let reference = fit(reference, w, h, Filter::Box);
|
||||
if (reference.width, reference.height) != (w, h) {
|
||||
return None;
|
||||
}
|
||||
let mut gradients = Rgb32f::new(w, h);
|
||||
let mut edges = Vec::new();
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let derivative = |image: &Rgb32f| {
|
||||
[
|
||||
luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]),
|
||||
luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]),
|
||||
]
|
||||
};
|
||||
let d = derivative(&sensor);
|
||||
gradients.data[y * w + x] = [d[0], d[1], 0.0];
|
||||
let d = derivative(&reference);
|
||||
let magnitude = d[0].hypot(d[1]);
|
||||
let value = luminance_2020(reference.data[y * w + x]);
|
||||
if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 {
|
||||
edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if edges.len() < 192 {
|
||||
return None;
|
||||
}
|
||||
let score = |framing: Cr3Framing, held_out: bool| -> Option<f64> {
|
||||
let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize);
|
||||
for &(x, y, target, magnitude, holdout) in &edges {
|
||||
if holdout != held_out {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
let (sx, sy) = framing.source(x, y, w, h);
|
||||
if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 {
|
||||
continue;
|
||||
}
|
||||
let sample = gradients.sample_bilinear(sx, sy);
|
||||
let length = sample[0].hypot(sample[1]);
|
||||
if !length.is_finite() || length <= 0.01 {
|
||||
continue;
|
||||
}
|
||||
let k = f64::from(magnitude.min(0.25));
|
||||
agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude));
|
||||
weight += k;
|
||||
valid += 1;
|
||||
}
|
||||
(valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight)
|
||||
};
|
||||
let before = score(Cr3Framing::IDENTITY, false)?;
|
||||
let mut best = (before, Cr3Framing::IDENTITY);
|
||||
// At most 10% zoom and two proxy pixels of translation; a coarse search followed by one
|
||||
// local refinement prevents unbounded optimisation on the photograph's contents.
|
||||
for s in -10..=10 {
|
||||
for tx in -4..=4 {
|
||||
for ty in -4..=4 {
|
||||
let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] };
|
||||
if let Some(value) = score(framing, false)
|
||||
&& value > best.0
|
||||
{
|
||||
best = (value, framing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let coarse = best.1;
|
||||
for s in -5..=5 {
|
||||
for tx in -4..=4 {
|
||||
for ty in -4..=4 {
|
||||
let framing = Cr3Framing {
|
||||
scale: coarse.scale + s as f32 * 0.002,
|
||||
offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32],
|
||||
};
|
||||
if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = score(framing, false)
|
||||
&& value > best.0
|
||||
{
|
||||
best = (value, framing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?);
|
||||
if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 {
|
||||
return None;
|
||||
}
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1);
|
||||
}
|
||||
Some(best.1)
|
||||
}
|
||||
|
||||
fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f {
|
||||
let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor };
|
||||
let (w, h) = (sensor.width, sensor.height);
|
||||
let mut aligned = Rgb32f::new(w, h);
|
||||
for (i, pixel) in aligned.data.iter_mut().enumerate() {
|
||||
let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||||
*pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] };
|
||||
}
|
||||
aligned
|
||||
}
|
||||
|
||||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||||
/// colour matrices, other files or unusable previews.
|
||||
@@ -101,7 +251,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, None)?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
|
||||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||||
}
|
||||
|
||||
@@ -173,7 +323,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||||
/// colours (see [`EDGE_CONTRAST`]).
|
||||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -197,7 +347,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let max = output.iter().copied().fold(0.0, f32::max);
|
||||
colour += usize::from(max - min > 0.05);
|
||||
colour += usize::from(max - min > min_chroma);
|
||||
pairs.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
|
||||
@@ -252,11 +402,22 @@ const EDGE_CONTRAST: f32 = 3.0;
|
||||
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
|
||||
/// valid when the camera JPEG's geometry differs slightly from the raw's.
|
||||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
let has_profile = colour.is_some();
|
||||
// A different picture style can make a camera profile fail the gates. Preserve the
|
||||
// photo's own colour fit before resorting to the neutral fallback.
|
||||
fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
|
||||
}
|
||||
|
||||
/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
|
||||
/// the pixels away from edges (issue #232).
|
||||
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
|
||||
}
|
||||
|
||||
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, edge_limit)?;
|
||||
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
|
||||
// learn a new colour matrix. Still reject monochrome references.
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
|
||||
let candidates = match colour {
|
||||
Some(given) => vec![given],
|
||||
None => {
|
||||
@@ -778,7 +939,7 @@ mod tests {
|
||||
let mut pool = Vec::new();
|
||||
for seed in 0..4 {
|
||||
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
|
||||
pool.extend(collect_pairs(&sensor, &reference, None).unwrap().0);
|
||||
pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
|
||||
}
|
||||
let (matrix, _) = fit_profile(&pool).unwrap();
|
||||
// the camera's colours (luminance-normalised: the tone curve sets brightness)
|
||||
@@ -805,6 +966,48 @@ mod tests {
|
||||
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_fits_tone_without_learning_colour_from_a_dull_scene() {
|
||||
let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]);
|
||||
let mut sensor = Rgb32f::new(64, 48);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
*src = [0.02 + (i % 997) as f32 * 0.00045; 3];
|
||||
let p = matrix.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix");
|
||||
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
|
||||
assert_eq!(look.matrix, matrix);
|
||||
reference.map_in_place(|p| [luminance_2020(p); 3]);
|
||||
assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_profile_preserves_a_usable_per_photo_fit() {
|
||||
let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
|
||||
*src = std::array::from_fn(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
0.05 + (seed % 1000) as f32 * 0.0003
|
||||
});
|
||||
let p = matrix.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999));
|
||||
}
|
||||
let per_photo = fit_pairs(&sensor, &reference).unwrap();
|
||||
let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap();
|
||||
assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit");
|
||||
assert_eq!(look.tone, per_photo.tone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_passes_black_and_non_finite_pixels_through() {
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
@@ -816,9 +1019,73 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
|
||||
fn supported_raws_get_a_file_local_look() {
|
||||
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
|
||||
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
// DNG carries its own colour model
|
||||
assert!(!file_local_look(RawFormat::Dng));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() {
|
||||
let (w, h) = (192, 128);
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
for (i, pixel) in sensor.data.iter_mut().enumerate() {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos();
|
||||
*pixel = [value; 3];
|
||||
}
|
||||
let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] };
|
||||
let mut reference = Rgb32f::new(w, h);
|
||||
for (i, pixel) in reference.data.iter_mut().enumerate() {
|
||||
let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||||
*pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7));
|
||||
}
|
||||
let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone");
|
||||
assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}");
|
||||
assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}");
|
||||
assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}");
|
||||
assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged");
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing");
|
||||
for (i, pixel) in reference.data.iter_mut().enumerate() {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
*pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3];
|
||||
}
|
||||
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing");
|
||||
}
|
||||
|
||||
/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus
|
||||
/// download or an unfinished codec cannot make this test claim a successful RAW calibration.
|
||||
#[test]
|
||||
fn corpus_cr3_gets_a_framing_aligned_camera_look() {
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/cr3-canon-r100-raw.cr3");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let raw = match lightcraft_raw::decode(&bytes) {
|
||||
Ok(raw) => raw,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
eprintln!("skip CR3 sensor decoder: {why}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
|
||||
};
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform);
|
||||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.raw && info.relative_wb);
|
||||
assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0));
|
||||
assert_eq!(info.camera_tone.is_some(), look.is_some());
|
||||
}
|
||||
|
||||
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
|
||||
@@ -918,6 +1185,28 @@ mod tests {
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
|
||||
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
|
||||
/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
|
||||
/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
|
||||
/// corpus files.
|
||||
#[test]
|
||||
fn corpus_raf_colour_and_white_balance() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
|
||||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
|
||||
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
|
||||
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
|
||||
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
|
||||
assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Camera colour profiles of our own, pooled from many photos of one camera model.
|
||||
//!
|
||||
//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
|
||||
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
|
||||
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
|
||||
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
|
||||
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
|
||||
@@ -24,7 +24,11 @@ const MAX_FILE: u64 = 4 << 20;
|
||||
|
||||
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
|
||||
/// `(model, JSON)`.
|
||||
pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))];
|
||||
pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CameraProfile {
|
||||
@@ -61,19 +65,8 @@ impl CameraProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// LightCraft's configuration folder (settings, GPU marker, camera profiles).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
/// LightCraft's configuration folder (settings, GPU marker, camera profiles); one helper for the app.
|
||||
pub use crate::config::config_dir;
|
||||
|
||||
/// Where camera profiles are read from and written to.
|
||||
pub fn dir() -> Option<PathBuf> {
|
||||
@@ -196,8 +189,8 @@ pub struct Pool {
|
||||
const PAIRS_PER_FILE: usize = 4000;
|
||||
|
||||
impl Pool {
|
||||
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
|
||||
/// NEF without colour matrices, no usable camera JPEG, too little colour).
|
||||
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW,
|
||||
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
|
||||
@@ -268,13 +268,9 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
/// the folder itself.
|
||||
fn follow_library_folder(chosen: &mut Option<String>, from: &str, to: &str) {
|
||||
let Some(c) = chosen.as_deref() else { return };
|
||||
let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from));
|
||||
if fk.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) {
|
||||
let rest = rest.trim_start_matches('/');
|
||||
*chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) });
|
||||
if let Some(rest) = lightcraft_catalog::query::folder_rest(c, from) {
|
||||
// joined by the platform, so the result is spelled like `to` and like a path read from disk
|
||||
*chosen = Some(rest.iter().fold(std::path::PathBuf::from(to), |p, name| p.join(name)).to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
//! Denoise commands (`denoise.*`): the models (list, install, download, test, remove, choose), the settings, and the
|
||||
//! work (queue, cancel, status, pump, clear). The machinery they drive is [`crate::denoise`].
|
||||
//!
|
||||
//! Models live in the folder the host provides (`Session::denoise.models_dir`): one subfolder per model with
|
||||
//! `model.onnx`, `denoise-model.json` (its manifest) and `installed.json` (what the user accepted and the file's hash),
|
||||
//! plus `settings.json` beside them. Model files, archives and manifests are hostile input: sizes are capped, ids are
|
||||
//! validated before they become folder names, and nothing outside a model's own folder is ever touched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, PoisonError, mpsc};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_denoise::archive;
|
||||
use lightcraft_denoise::hash::sha256_file;
|
||||
use lightcraft_denoise::known::{self, Known};
|
||||
use lightcraft_denoise::manifest::{self, DenoiserManifest, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::denoise::{Pace, RunOn, Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings};
|
||||
use crate::model_download::State;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
|
||||
s.denoise.models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep denoise models (the desktop app does)"))
|
||||
}
|
||||
|
||||
/// What a command says about a model.
|
||||
fn row(m: &DenoiserManifest, download_host: Option<String>, installed: bool, selected: bool, accepted: &Value) -> Value {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"licence": m.licence,
|
||||
"provenance": m.provenance,
|
||||
"source": m.source,
|
||||
"sizeBytes": m.size_bytes,
|
||||
"sha256": m.sha256,
|
||||
"tile": m.tile,
|
||||
"known": known::find(&m.id).is_some(),
|
||||
// a pinned address LightCraft can fetch it from (the user presses Download), and which site that is
|
||||
"downloadHost": download_host,
|
||||
"installed": installed,
|
||||
"selected": selected,
|
||||
"accepted": accepted,
|
||||
})
|
||||
}
|
||||
|
||||
fn host_of(k: &Known) -> Option<String> {
|
||||
k.download.as_ref().map(|d| lightcraft_denoise::known::host(&d.url).to_string())
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(true);
|
||||
let dir = s.denoise.models_dir.clone();
|
||||
let st = dir.as_deref().map(read_settings).unwrap_or_default();
|
||||
let installed = dir.as_deref().map(installed_models).unwrap_or_default();
|
||||
let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id);
|
||||
let chosen = |id: &str| st.model.as_deref() == Some(id);
|
||||
let mut models: Vec<Value> = Vec::new();
|
||||
for k in known::all() {
|
||||
let found = on_disk(&k.manifest.id);
|
||||
models.push(row(&k.manifest, host_of(&k), found.is_some(), chosen(&k.manifest.id), found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
for i in installed.iter().filter(|i| known::find(&i.manifest.id).is_none()) {
|
||||
models.push(row(&i.manifest, None, true, chosen(&i.manifest.id), &i.accepted));
|
||||
}
|
||||
Ok(json!({
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
|
||||
"model": st.model,
|
||||
// whether this build can run denoise models (the `denoise` feature: our pure-Rust CPU/GPU runners)
|
||||
"runtime": cfg!(feature = "denoise"),
|
||||
"auto": st.auto(),
|
||||
"cacheGb": st.cache_bytes() >> 30,
|
||||
"threads": st.threads,
|
||||
"runOn": st.run_on().name(),
|
||||
"models": models,
|
||||
}))
|
||||
}
|
||||
|
||||
/// What a file the user pointed at is, as far as installing it goes.
|
||||
struct Source {
|
||||
manifest: DenoiserManifest,
|
||||
/// The `.onnx` inside the archive; `None` when the file is the `.onnx`.
|
||||
entry: Option<&'static str>,
|
||||
file_name: String,
|
||||
}
|
||||
|
||||
fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result<Source> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| bad(c, format!("cannot read `{}`: {e}", path.display())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(bad(c, format!("`{}` is not a file", path.display())));
|
||||
}
|
||||
if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES {
|
||||
return Err(bad(c, "a model file must be between 1 byte and 2 GiB"));
|
||||
}
|
||||
let sha256 = match sha_known {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_file(path).map_err(|e| fail(&format!("could not read {}", path.display()), e))?,
|
||||
};
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// an archive LightCraft knows (the file darktable publishes the model in)
|
||||
if let Some(k) = known::all().into_iter().find(|k| k.download.as_ref().is_some_and(|d| d.sha256 == sha256)) {
|
||||
return Ok(Source { manifest: k.manifest, entry: k.entry, file_name });
|
||||
}
|
||||
// the `.onnx` of a known model
|
||||
if let Some(m) = known::by_sha256(&sha256) {
|
||||
return Ok(Source { manifest: m, entry: None, file_name });
|
||||
}
|
||||
// an `.onnx` with its manifest beside it
|
||||
let sibling = path.with_file_name("denoise-model.json");
|
||||
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
|
||||
&& let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES)
|
||||
{
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(c, format!("{}: {e}", sibling.display())))?;
|
||||
return Ok(Source { manifest: m, entry: None, file_name });
|
||||
}
|
||||
Err(bad(
|
||||
c,
|
||||
"LightCraft does not know this file. A denoise model is an .onnx file with a denoise-model.json beside it that says how to use it \
|
||||
(see docs/denoise.md), or an archive LightCraft can fetch itself (`denoise.models.download`)",
|
||||
))
|
||||
}
|
||||
|
||||
/// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing
|
||||
/// a model is how someone says they want it. `verified` is for a download whose hash was already checked.
|
||||
fn install_file(
|
||||
dir: &Path,
|
||||
loader: &crate::denoise::Loader,
|
||||
run_on: RunOn,
|
||||
accepted_at: String,
|
||||
c: &str,
|
||||
src: &Path,
|
||||
acknowledged: bool,
|
||||
verified: bool,
|
||||
) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
|
||||
let ident = identify(src, c, None)?;
|
||||
let m = ident.manifest.clone();
|
||||
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
|
||||
if !acknowledged {
|
||||
return Err(bad(
|
||||
c,
|
||||
"the licence has not been accepted: show the user its terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
let home = dir.join(&m.id);
|
||||
if home.join("model.onnx").exists() {
|
||||
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
|
||||
}
|
||||
std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?;
|
||||
let mut cleanup = InstallCleanup { home: home.clone(), armed: true };
|
||||
let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx"));
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let placed: Result<()> = match ident.entry {
|
||||
Some(entry) => {
|
||||
archive::extract(src, entry, &part, MAX_MODEL_BYTES).map(|_| ()).map_err(|e| fail("could not take the model out of the archive", e))
|
||||
}
|
||||
None if verified && std::fs::rename(src, &part).is_ok() => Ok(()),
|
||||
None => std::fs::copy(src, &part).map(|_| ()).map_err(|e| fail("could not copy the model (is the disk full?)", e)),
|
||||
};
|
||||
let sha256 = placed.and_then(|_| sha256_file(&part).map_err(|e| fail("could not check the model", e))).and_then(|h| match &m.sha256 {
|
||||
Some(want) if *want != h => Err(EngineError::Other("the model file is not the one its description names (its SHA-256 differs)".into())),
|
||||
_ => Ok(h),
|
||||
});
|
||||
let sha256 = match sha256 {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Test the staged file first. A failed replacement preserves the installed model.
|
||||
let model = loader(&part, &m).map_err(|e| bad(c, e))?;
|
||||
let test = match model.self_test(run_on) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
};
|
||||
// An installed id is immutable: installing again must never destroy a usable model.
|
||||
if final_path.exists() {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
|
||||
}
|
||||
std::fs::rename(&part, &final_path).map_err(|e| fail("could not finish the copy", e))?;
|
||||
write_atomic(&home.join("denoise-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?).map_err(EngineError::Other)?;
|
||||
let accepted = json!({
|
||||
"acceptedAt": accepted_at,
|
||||
"licence": m.licence.name,
|
||||
"commercial": m.licence.commercial,
|
||||
"fileName": ident.file_name,
|
||||
"sha256": sha256,
|
||||
"selfTest": test,
|
||||
});
|
||||
write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?).map_err(EngineError::Other)?;
|
||||
cleanup.armed = false;
|
||||
Ok((json!({"installed": row(&m, known::find(&m.id).and_then(|k| host_of(&k)), true, false, &accepted), "model": m.id}), model))
|
||||
}
|
||||
|
||||
/// Removes only files created for this new installation, including on a worker panic.
|
||||
struct InstallCleanup {
|
||||
home: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for InstallCleanup {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
for name in ["model.onnx.part", "model.onnx", "denoise-model.json", "installed.json"] {
|
||||
let _ = std::fs::remove_file(self.home.join(name));
|
||||
}
|
||||
let _ = std::fs::remove_dir(&self.home);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct InstallJob {
|
||||
id: String,
|
||||
activate: bool,
|
||||
result: mpsc::Receiver<std::result::Result<(Value, Arc<dyn crate::denoise::Model>), String>>,
|
||||
}
|
||||
|
||||
fn activate_install(s: &mut Session, result: &Value, model: Arc<dyn crate::denoise::Model>, activate: bool) -> Result<Value> {
|
||||
let dir = models_dir(s, "denoise.models.install")?;
|
||||
let id = result["installed"]["id"].as_str().ok_or_else(|| bad("denoise.models.install", "missing installed id"))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if activate {
|
||||
st.model = Some(id.to_string());
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
if let Some(a) = &s.denoise.active
|
||||
&& a.id == id
|
||||
{
|
||||
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(model);
|
||||
}
|
||||
let mut v = result.clone();
|
||||
v["installed"]["selected"] = json!(st.model.as_deref() == Some(id));
|
||||
v["model"] = json!(st.model);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, verified: bool) -> Result<Value> {
|
||||
const C: &str = "denoise.models.install";
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let dir = models_dir(s, C)?;
|
||||
let loader = s.denoise.loader.clone();
|
||||
let run_on = s.denoise.settings.run_on();
|
||||
let at = (s.clock)();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("denoise-model-install".into())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024);
|
||||
install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into()));
|
||||
if verified {
|
||||
let _ = std::fs::remove_file(&src);
|
||||
}
|
||||
let _ = tx.send(outcome);
|
||||
})
|
||||
.map_err(|e| bad(C, format!("could not start installation: {e}")))?;
|
||||
s.denoise.install = Some(InstallJob { id: id.clone(), activate, result: rx });
|
||||
s.denoise.downloads.set_outcome(&id, State::Installing);
|
||||
Ok(json!({"started": id}))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.install";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let acknowledged = bool_or(p, "acknowledged", false);
|
||||
let activate = bool_or(p, "activate", true);
|
||||
if !acknowledged {
|
||||
return Err(bad(C, "the licence has not been accepted: show its terms first"));
|
||||
}
|
||||
if bool_or(p, "background", false) {
|
||||
return start_install(s, PathBuf::from(path), "local-install".into(), activate, false);
|
||||
}
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let (result, model) =
|
||||
install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?;
|
||||
activate_install(s, &result, model, activate)
|
||||
}
|
||||
|
||||
/// Read only the small adjacent manifest; hashing and execution happen in the install worker.
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.inspect";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let src = Path::new(path);
|
||||
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
|
||||
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
|
||||
}
|
||||
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
|
||||
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
|
||||
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
|
||||
}
|
||||
|
||||
/// What an error says, without the command it came from (for a line shown to the user).
|
||||
fn plain(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every download that has arrived: each was accepted when it was started, so it is installed and chosen without
|
||||
/// another question. Called by `denoise.models.downloads` and by every frame's `denoise.pump`.
|
||||
pub(crate) fn finish_downloads(s: &mut Session) {
|
||||
if let Some(job) = &s.denoise.install {
|
||||
let outcome = match job.result.try_recv() {
|
||||
Ok(v) => Some(v),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => Some(Err("the installation worker stopped".into())),
|
||||
};
|
||||
if let Some(outcome) = outcome
|
||||
&& let Some(job) = s.denoise.install.take()
|
||||
{
|
||||
let state = match outcome {
|
||||
Ok((v, model)) => match activate_install(s, &v, model, job.activate) {
|
||||
Ok(_) => State::Installed,
|
||||
Err(e) => State::Failed(plain(&e)),
|
||||
},
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
s.denoise.downloads.set_outcome(&job.id, state);
|
||||
}
|
||||
}
|
||||
if s.denoise.install.is_none()
|
||||
&& let Some((id, path, _sha)) = s.denoise.downloads.finished().into_iter().next()
|
||||
&& let Err(e) = start_install(s, path, id.clone(), true, true)
|
||||
{
|
||||
s.denoise.downloads.set_outcome(&id, State::Failed(plain(&e)));
|
||||
}
|
||||
}
|
||||
|
||||
fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.download";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
let k = known::find(id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
let spec = k.download.clone().ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
if !cfg!(feature = "denoise") {
|
||||
return Err(bad(C, "this build cannot run denoise models, so there is nothing to download for it"));
|
||||
}
|
||||
if !bool_or(p, "acknowledged", false) {
|
||||
return Err(bad(
|
||||
C,
|
||||
"the licence has not been accepted: show the user the model's terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
if installed_models(&dir).iter().any(|i| i.manifest.id == spec.id) {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = lightcraft_denoise::known::host(&spec.url).to_string();
|
||||
s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
fn download_row(id: &str, state: &State) -> Value {
|
||||
let from = known::find(id).and_then(|k| host_of(&k));
|
||||
match state {
|
||||
State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}),
|
||||
State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}),
|
||||
State::Installing => json!({"id": id, "state": "installing", "from": from}),
|
||||
State::Installed => json!({"id": id, "state": "installed", "from": from}),
|
||||
State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}),
|
||||
State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}),
|
||||
}
|
||||
}
|
||||
|
||||
fn downloads(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
finish_downloads(s);
|
||||
let all: Vec<Value> = s.denoise.downloads.snapshot().iter().map(|(id, st)| download_row(id, st)).collect();
|
||||
Ok(json!({"running": s.denoise.downloads.running(), "downloads": all}))
|
||||
}
|
||||
|
||||
fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.downloadCancel";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
Ok(json!({"discarded": s.denoise.downloads.discard(id)}))
|
||||
}
|
||||
|
||||
fn test(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.test";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
let found = installed_models(&dir).into_iter().find(|i| i.manifest.id == id).ok_or_else(|| bad(C, "that model is not installed"))?;
|
||||
let run_on = s.denoise.settings.run_on();
|
||||
let result = (s.denoise.loader)(&found.onnx, &found.manifest).and_then(|m| m.self_test(run_on));
|
||||
let (ok, detail) = match &result {
|
||||
Ok(v) => (true, v.clone()),
|
||||
Err(e) => (false, json!(e)),
|
||||
};
|
||||
// keep the latest outcome with the model's record
|
||||
let mut record = found.accepted;
|
||||
if let Some(o) = record.as_object_mut() {
|
||||
o.insert("selfTest".into(), if ok { detail.clone() } else { json!({"ok": false, "error": detail}) });
|
||||
write_atomic(&found.onnx.with_file_name("installed.json"), &serde_json::to_vec_pretty(&record).map_err(|e| fail("record", e))?)
|
||||
.map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(json!({"id": id, "ok": ok, "result": detail}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.remove";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !lightcraft_denoise::licence::valid_id(id) {
|
||||
return Err(bad(C, "not a model id"));
|
||||
}
|
||||
let home = dir.join(id);
|
||||
// only a folder that is a model (it has a manifest) is ever deleted
|
||||
if !home.join("denoise-model.json").is_file() {
|
||||
return Err(bad(C, "that model is not installed"));
|
||||
}
|
||||
std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if st.model.as_deref() == Some(id) {
|
||||
st.model = installed_models(&dir).into_iter().map(|i| i.manifest.id).find(|m| m != id);
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
Ok(json!({"removed": id, "model": st.model}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.select";
|
||||
let dir = models_dir(s, C)?;
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()),
|
||||
};
|
||||
if let Some(id) = &id
|
||||
&& !installed_models(&dir).iter().any(|i| &i.manifest.id == id)
|
||||
{
|
||||
return Err(bad(C, "that denoise model is not installed"));
|
||||
}
|
||||
let mut st = read_settings(&dir);
|
||||
st.model = id;
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
Ok(json!({"model": st.model}))
|
||||
}
|
||||
|
||||
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.settings";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st: Settings = read_settings(&dir);
|
||||
if let Some(v) = p.get("auto") {
|
||||
st.auto = Some(v.as_bool().ok_or_else(|| bad(C, "`auto` must be true or false"))?);
|
||||
}
|
||||
if let Some(v) = p.get("cacheGb") {
|
||||
let n =
|
||||
v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad(C, "`cacheGb` must be a whole number of gigabytes from 1 to 10000"))?;
|
||||
st.cache_gb = Some(n as u32);
|
||||
}
|
||||
if let Some(v) = p.get("threads") {
|
||||
st.threads = match v {
|
||||
Value::Null => None,
|
||||
v => Some(v.as_u64().filter(|n| (1..=64).contains(n)).ok_or_else(|| bad(C, "`threads` must be from 1 to 64, or null"))? as u32),
|
||||
};
|
||||
}
|
||||
let run_on = match p.get("runOn") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_str().and_then(RunOn::parse).ok_or_else(|| bad(C, "`runOn` must be auto, gpu or cpu"))?),
|
||||
};
|
||||
if let Some(r) = run_on {
|
||||
st.run_on = Some(r.name().to_string());
|
||||
st.gpu = None;
|
||||
}
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
if run_on.is_some() {
|
||||
// a choice of where denoise runs is also a go-ahead to try the card again: a set-up that crashed or gave up
|
||||
// before is forgotten, and the loaded model sets the card up afresh for the next photo
|
||||
forget_gpu_set_ups(&dir);
|
||||
if let Some(a) = &s.denoise.active {
|
||||
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
}
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
// a smaller limit takes effect now
|
||||
s.denoise_evict(None);
|
||||
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
|
||||
}
|
||||
|
||||
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
fn status_json(s: &Session, photo: Option<PhotoId>) -> Value {
|
||||
let (files, bytes) = s.denoise_cache_usage();
|
||||
let st = &s.denoise.settings;
|
||||
let model = s.denoise.active.as_ref().map(|a| json!({"id": a.id, "name": a.manifest.name}));
|
||||
let mut v = json!({
|
||||
"enabled": s.denoise.active.is_some(),
|
||||
"model": model,
|
||||
"runtime": cfg!(feature = "denoise"),
|
||||
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
|
||||
"queued": s.denoise.queued(),
|
||||
"running": s.denoise_running_json(),
|
||||
"ready": s.media.denoise.len(),
|
||||
"made": s.denoise_made(),
|
||||
"auto": st.auto(),
|
||||
"runOn": st.run_on().name(),
|
||||
"device": s.denoise_device(),
|
||||
"cache": {"files": files, "bytes": bytes, "limitBytes": st.cache_bytes()},
|
||||
"generation": s.denoise.generation,
|
||||
});
|
||||
if let (Some(id), Some(o)) = (photo, v.as_object_mut()) {
|
||||
o.insert("photo".into(), serde_json::to_value(s.denoise_photo_state(id)).unwrap_or(Value::Null));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A per-photo switch, independent of the selection and Auto Sync. Turning on starts at 50%.
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.toggle";
|
||||
let id = match p.get("id") {
|
||||
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, "`id` must be a photo id"))?),
|
||||
None => s.active().ok_or_else(|| bad(C, "select a raw photo"))?,
|
||||
};
|
||||
let photo = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?;
|
||||
if !crate::denoise::eligible(photo) {
|
||||
return Err(bad(C, "AI Denoise applies to raw photos developed from their sensor data"));
|
||||
}
|
||||
let mut d = (*s.develop_of(id).ok_or_else(|| bad(C, "no such photo"))?).clone();
|
||||
let on = match p.get("enabled") {
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`enabled` must be true or false"))?,
|
||||
None => !d.enhance.denoise_enabled(),
|
||||
};
|
||||
if on {
|
||||
if !d.enhance.denoise.is_finite() {
|
||||
return Err(bad(C, "the photo has an invalid Denoise amount"));
|
||||
}
|
||||
s.denoise_refresh_active(true);
|
||||
if s.denoise.active.is_none() {
|
||||
return Err(bad(C, "AI Denoise needs a model: install one in Settings > AI Denoise"));
|
||||
}
|
||||
}
|
||||
// The switch is its own setting: the Amount stays where it is (0 included) when it is flipped, so flipping compares
|
||||
// with and without. A photo that never had an Amount starts at 50 the first time.
|
||||
let amount = if on && d.enhance.denoise_on.is_none() && d.enhance.denoise <= 0.0 { 50.0 } else { d.enhance.denoise };
|
||||
if d.enhance.denoise != amount || d.enhance.denoise_on != Some(on) {
|
||||
s.end_interaction()?;
|
||||
d.enhance.denoise = amount;
|
||||
d.enhance.denoise_on = Some(on);
|
||||
let op = s.develop_op(id, d, "AI Denoise").ok_or_else(|| bad(C, "no such photo"))?;
|
||||
s.commit("AI Denoise", op)?;
|
||||
}
|
||||
if on {
|
||||
s.denoise_enqueue(&[id], true, false);
|
||||
} else {
|
||||
s.denoise_cancel(Some(id), false);
|
||||
}
|
||||
Ok(json!({"id": id.0, "enabled": on, "amount": amount}))
|
||||
}
|
||||
|
||||
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.queue";
|
||||
s.denoise_refresh_active(true);
|
||||
if s.denoise.active.is_none() {
|
||||
return Err(bad(C, "no denoise model is chosen: install one first (`denoise.models.download` or `denoise.models.install`)"));
|
||||
}
|
||||
if s.denoise_products_dir().is_none() {
|
||||
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
|
||||
}
|
||||
let ids = match ids_param(p) {
|
||||
Some(ids) => ids,
|
||||
None => match str_param(p, "scope").unwrap_or("selected") {
|
||||
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
|
||||
"visible" => s.visible_cloned().to_vec(),
|
||||
"withAmount" => s.catalog.photos().filter(|ph| ph.develop.denoise_amount() > 0.0).map(|ph| ph.id).collect(),
|
||||
other => return Err(bad(C, format!("unknown scope `{other}`: use selected, visible or withAmount"))),
|
||||
},
|
||||
};
|
||||
let queued = s.denoise_enqueue(&ids, false, bool_or(p, "retry", false));
|
||||
let mut v = status_json(s, None);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("added".into(), json!(queued));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
|
||||
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
|
||||
Ok(json!({"cancelled": n}))
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(false);
|
||||
Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
|
||||
}
|
||||
|
||||
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
|
||||
Ok(json!({
|
||||
"active": s.denoise.active.is_some(),
|
||||
"running": s.denoise_running_json(),
|
||||
"queued": s.denoise.queued(),
|
||||
"ready": s.media.denoise.len(),
|
||||
"generation": s.denoise.generation,
|
||||
"downloading": s.denoise.downloads.running(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (files, bytes) = s.denoise_clear();
|
||||
Ok(json!({"deleted": files, "bytes": bytes}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "denoise.models.list", "Denoise Models", [], None, "{} → {dir, productsDir, model, runtime, auto, cacheGb, threads, runOn, models: [{id, name, version, licence{name, commercial, url, notice}, provenance, source, sizeBytes, sha256, tile, known, downloadHost, installed, selected, accepted}]}", always, list),
|
||||
cmd!(query "denoise.models.inspect", "Inspect Denoise Model", [], None, "{path} → model terms from an adjacent manifest; never executes the file", always, inspect),
|
||||
cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install),
|
||||
cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model LightCraft has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with lightcraft-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download),
|
||||
cmd!(query "denoise.models.downloads", "Denoise Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived", always, downloads),
|
||||
cmd!(query "denoise.models.downloadCancel", "Cancel Denoise Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel),
|
||||
cmd!(query "denoise.models.test", "Test Denoise Model", [], None, "{id} → {ok, result} — load an installed model and check it gives sensible pictures; needs the denoise runtime", always, test),
|
||||
cmd!(query "denoise.models.remove", "Remove Denoise Model", [], None, "{id} → {removed, model} — delete an installed model (its cached pictures stay until they are evicted)", always, remove),
|
||||
cmd!(query "denoise.models.select", "Choose Denoise Model", [], None, "{id: installed model | null} → {model} — null switches denoise off", always, select),
|
||||
cmd!(query "denoise.settings", "Denoise Settings", [], None, "{auto?: bool, cacheGb?: 1..10000, threads?: 1..64 | null, runOn?: auto | gpu | cpu} → the settings — `auto`: make the picture of the photos being looked at that have a Denoise amount; `cacheGb`: how much the cached pictures may take; `threads`: most tiles run at once; `runOn`: where the model runs — `auto` (the default) the graphics card where it is faster than the processor (both timed on this computer when the card is set up), `gpu` the card whenever it can run the model, `cpu` the processor. Setting `runOn` also lets the card be tried again after a set-up that failed or closed LightCraft", always, settings),
|
||||
cmd!(
|
||||
"denoise.toggle",
|
||||
"AI Denoise",
|
||||
[],
|
||||
None,
|
||||
"{id?: photoId, enabled?: bool} → {id, enabled, amount} — turn AI Denoise on (50% initially) or off for one raw photo, independent of selection and Auto Sync; needs an installed model to turn on",
|
||||
always,
|
||||
toggle
|
||||
),
|
||||
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
|
||||
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
|
||||
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `faces.detect`: find faces with the YuNet detector the user has downloaded (Settings > Faces).
|
||||
//!
|
||||
//! The photo is rendered upright and uncropped with default settings (so edits, crops and rotations you have
|
||||
//! made do not matter), the detector runs on it, and the faces come back as fractions of the photo's width
|
||||
//! and height, the same frame face regions from XMP use. Unless `apply` is false, they are added to the photo as
|
||||
//! unnamed face regions in one undoable step; earlier detections are replaced, and regions that came from XMP (or
|
||||
//! that you drew or named) are never touched. Like every region edit it is catalog-only: no sidecar is written.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::Op;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_faces::known::YUNET_ID;
|
||||
use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_meta::{Region, RegionKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection};
|
||||
use crate::{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.
|
||||
pub(crate) const MARK: &str = "Detected by ";
|
||||
pub(crate) const LABEL: &str = "YuNet 2023mar";
|
||||
/// Long edge of the picture the detector looks at (it shrinks it to its own 640 anyway).
|
||||
const LOOK_EDGE: usize = 1280;
|
||||
/// YuNet is 232 KB; a file this much larger in its folder is not it.
|
||||
const MAX_MODEL_BYTES: u64 = 8 << 20;
|
||||
/// How much a detection must overlap a region the photo already has to count as the same face.
|
||||
const EXISTING_IOU: f64 = 0.3;
|
||||
|
||||
/// What to tell someone who has not downloaded the detector yet.
|
||||
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
|
||||
|
||||
/// The 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> {
|
||||
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 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());
|
||||
}
|
||||
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some((p, d)) = loaded.as_ref()
|
||||
&& *p == path
|
||||
{
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let len = std::fs::metadata(&path).map_err(|e| 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());
|
||||
}
|
||||
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"))?);
|
||||
*loaded = Some((path, d.clone()));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn face_json(f: &Face) -> Value {
|
||||
json!({
|
||||
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
|
||||
"score": f.score,
|
||||
"landmarks": f.landmarks.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A detected face that lies on a region the photo already has (from XMP, drawn or named) is that region, not a new face.
|
||||
fn overlaps_existing(f: &Face, regions: &[Region]) -> bool {
|
||||
regions.iter().any(|r| {
|
||||
let (w, h) = (
|
||||
(r.rect.x1.min(f64::from(f.x1)) - r.rect.x0.max(f64::from(f.x0))).max(0.0),
|
||||
(r.rect.y1.min(f64::from(f.y1)) - r.rect.y0.max(f64::from(f.y0))).max(0.0),
|
||||
);
|
||||
let inter = w * h;
|
||||
let union = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) + f64::from((f.x1 - f.x0) * (f.y1 - f.y0)) - inter;
|
||||
union > 0.0 && inter / union >= EXISTING_IOU
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_detected(r: &Region) -> bool {
|
||||
r.description.as_deref().is_some_and(|d| d.starts_with(MARK))
|
||||
}
|
||||
|
||||
/// The photo as the faces see it: upright, uncropped, default settings, as 8-bit RGB with its size, its long edge at
|
||||
/// most `edge`.
|
||||
pub(crate) fn render_rgb(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: usize) -> std::result::Result<(Vec<u8>, usize, usize), String> {
|
||||
let settings = DevelopSettings::default();
|
||||
let job = s.preview_job(id, edge, edge, false, &settings).ok_or("no such photo")?;
|
||||
let image = job.run().rendered?.image;
|
||||
let rgb: Vec<u8> = image.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
|
||||
Ok((rgb, image.width, image.height))
|
||||
}
|
||||
|
||||
fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let apply = p.get("apply").and_then(Value::as_bool).unwrap_or(true);
|
||||
let defaults = Options::default();
|
||||
let opts = Options {
|
||||
score: p.get("score").and_then(Value::as_f64).map_or(defaults.score, |v| v as f32),
|
||||
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 started = Instant::now();
|
||||
let (mut results, mut ops) = (Vec::new(), Vec::new());
|
||||
for id in s.targets(p) {
|
||||
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
results.push(json!({"id": id.0, "error": e}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let faces = match detector.detect(&rgb, width, height, &opts) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
results.push(json!({"id": id.0, "error": e.to_string()}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if apply && let Some(photo) = s.catalog.photo(id) {
|
||||
let mut meta = photo.meta.clone();
|
||||
meta.regions.retain(|r| !is_detected(r));
|
||||
let fresh: Vec<&Face> = faces.iter().filter(|f| !overlaps_existing(f, &meta.regions)).collect();
|
||||
for f in fresh {
|
||||
meta.regions.push(Region {
|
||||
rect: lightcraft_geom::Rect { x0: f64::from(f.x0), y0: f64::from(f.y0), x1: f64::from(f.x1), y1: f64::from(f.y1) },
|
||||
kind: RegionKind::Face,
|
||||
name: None,
|
||||
description: Some(format!("{MARK}{LABEL}")),
|
||||
});
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
results.push(json!({"id": id.0, "faces": faces.iter().map(face_json).collect::<Vec<_>>()}));
|
||||
}
|
||||
let applied = !ops.is_empty();
|
||||
if applied {
|
||||
s.commit("Detect Faces", Op::Batch { ops })?;
|
||||
// regions are not written to XMP, so a sidecar must not be rewritten for this
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
Ok(json!({"detector": LABEL, "applied": applied, "photos": results, "ms": started.elapsed().as_secs_f64() * 1000.0}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn face(x0: f32, y0: f32, x1: f32, y1: f32) -> Face {
|
||||
Face { x0, y0, x1, y1, score: 0.9, landmarks: [(0.0, 0.0); 5] }
|
||||
}
|
||||
fn region(x0: f64, y0: f64, x1: f64, y1: f64) -> Region {
|
||||
Region { rect: lightcraft_geom::Rect { x0, y0, x1, y1 }, kind: RegionKind::Face, name: Some("Ann".into()), description: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_detection_on_an_existing_region_is_not_a_new_face() {
|
||||
let existing = [region(0.40, 0.20, 0.60, 0.50), region(0.10, 0.10, 0.20, 0.20)];
|
||||
// the same face, a bit larger or shifted (a Lightroom box is not YuNet's box)
|
||||
assert!(overlaps_existing(&face(0.38, 0.18, 0.64, 0.54), &existing));
|
||||
assert!(overlaps_existing(&face(0.42, 0.22, 0.58, 0.48), &existing));
|
||||
// another face, a sliver of overlap, and no regions at all
|
||||
assert!(!overlaps_existing(&face(0.70, 0.20, 0.90, 0.50), &existing));
|
||||
assert!(!overlaps_existing(&face(0.59, 0.49, 0.80, 0.80), &existing));
|
||||
assert!(!overlaps_existing(&face(0.0, 0.0, 0.0, 0.0), &existing), "an empty box overlaps nothing");
|
||||
assert!(!overlaps_existing(&face(0.4, 0.2, 0.6, 0.5), &[]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Face model commands (`faces.*`): which models exist, inspecting a file the user dropped in, installing
|
||||
//! it once its licence is accepted, removing it, and the faces on/off setting.
|
||||
//!
|
||||
//! Models live in the folder the host provides (`Session::face_models_dir`): one subfolder per model with
|
||||
//! `model.onnx`, `face-model.json` (its manifest) and `installed.json` (what the user accepted), plus
|
||||
//! `settings.json` beside them. Model files and manifests are hostile input: sizes are capped, ids are
|
||||
//! validated before they become folder names, and nothing outside a model's own folder is ever touched.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_faces::catalog::{self, Catalog};
|
||||
use lightcraft_faces::hash::sha256_file;
|
||||
use lightcraft_faces::manifest::{self, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES, ModelManifest, Role};
|
||||
use lightcraft_faces::suggest::{Suggestion, suggest_with};
|
||||
use lightcraft_faces::{known, onnx};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::face_download::{self, State};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub(super) struct FaceSettings {
|
||||
pub(super) enabled: bool,
|
||||
/// The installed recogniser in use.
|
||||
pub(super) embedder: Option<String>,
|
||||
}
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
|
||||
s.face_models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep face models (the desktop app does)"))
|
||||
}
|
||||
|
||||
/// Read at most `max` bytes of a small file.
|
||||
fn read_capped(path: &Path, max: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?;
|
||||
(buf.len() <= max).then_some(buf)
|
||||
}
|
||||
|
||||
pub(super) fn read_settings(dir: &Path) -> FaceSettings {
|
||||
read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, bytes).map_err(|e| fail(&format!("could not write {}", path.display()), e))?;
|
||||
std::fs::rename(&part, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
fail(&format!("could not write {}", path.display()), e)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
|
||||
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
|
||||
}
|
||||
|
||||
pub(super) struct Installed {
|
||||
pub(super) manifest: ModelManifest,
|
||||
pub(super) accepted: Value,
|
||||
}
|
||||
|
||||
/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that
|
||||
/// does not validate or does not match its folder) is ignored, never trusted.
|
||||
pub(super) fn installed_models(dir: &Path) -> Vec<Installed> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
|
||||
if !manifest::valid_id(&name) || !p.is_dir() || !p.join("model.onnx").is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = read_capped(&p.join("face-model.json"), MAX_MANIFEST_BYTES).and_then(|b| manifest::parse(&b).ok()) else { continue };
|
||||
if m.id != name {
|
||||
continue;
|
||||
}
|
||||
let accepted = read_capped(&p.join("installed.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
|
||||
out.push(Installed { manifest: m, accepted });
|
||||
}
|
||||
out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the user's catalog (`catalog.json` in the models folder) anew: a few kilobytes, so it is always current.
|
||||
fn refresh_catalog(s: &mut Session) {
|
||||
let Some(dir) = s.face_models_dir.clone() else { return };
|
||||
let file = dir.join("catalog.json");
|
||||
s.face_catalog = match read_capped(&file, catalog::MAX_CATALOG_BYTES) {
|
||||
Some(bytes) => catalog::parse(&bytes),
|
||||
None if file.is_file() => catalog::parse(&vec![b' '; catalog::MAX_CATALOG_BYTES + 1]),
|
||||
None => Catalog::default(),
|
||||
};
|
||||
}
|
||||
|
||||
/// The manifests of the catalog's models (what a file's hash is matched against, besides the built-in ones).
|
||||
fn catalog_manifests(s: &Session) -> Vec<ModelManifest> {
|
||||
s.face_catalog.entries.iter().map(|e| e.manifest.clone()).collect()
|
||||
}
|
||||
|
||||
/// How to fetch a model: the built-in address, or the one in the user's catalog.
|
||||
fn download_spec(s: &Session, id: &str) -> Option<known::Download> {
|
||||
known::download(id).or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == id).and_then(catalog::Entry::download))
|
||||
}
|
||||
|
||||
fn row(s: &Session, m: &ModelManifest, installed: bool, selected: bool, accepted: &Value) -> Value {
|
||||
let from_catalog = s.face_catalog.entries.iter().any(|e| e.manifest.id == m.id);
|
||||
json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"role": m.role,
|
||||
"licence": m.licence,
|
||||
"provenance": m.provenance,
|
||||
"source": m.source,
|
||||
"sizeBytes": m.size_bytes,
|
||||
"sha256": m.sha256,
|
||||
// how fast it is as a multiple of a ResNet-100 model (computers differ, ratios between models hardly do), and
|
||||
// that in words; null when nobody has measured it
|
||||
"speed": m.speed,
|
||||
"speedText": m.speed.map(manifest::describe_speed).filter(|t| !t.is_empty()),
|
||||
"known": from_catalog || known::all().iter().any(|k| k.id == m.id),
|
||||
// from the user's own catalog file
|
||||
"fromCatalog": from_catalog,
|
||||
// a pinned address LightCraft can fetch it from (the user presses Download), and which site that is
|
||||
"downloadHost": download_spec(s, &m.id).map(|d| known::host(&d.url).to_string()),
|
||||
"installed": installed,
|
||||
"selected": selected,
|
||||
"accepted": accepted,
|
||||
})
|
||||
}
|
||||
|
||||
struct Inspection {
|
||||
sha256: String,
|
||||
size: u64,
|
||||
file_name: String,
|
||||
suggestion: Suggestion,
|
||||
}
|
||||
|
||||
/// Look at a model file. `known_sha` is its SHA-256 when something already checked it (a finished download), so a
|
||||
/// file of hundreds of megabytes is not read again just to be hashed.
|
||||
fn inspect_file(path: &str, cmd: &str, known_sha: Option<&str>, extra: &[ModelManifest]) -> Result<Inspection> {
|
||||
let p = Path::new(path);
|
||||
let meta = std::fs::metadata(p).map_err(|e| bad(cmd, format!("cannot read `{path}`: {e}")))?;
|
||||
if !meta.is_file() {
|
||||
return Err(bad(cmd, format!("`{path}` is not a file")));
|
||||
}
|
||||
if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES {
|
||||
return Err(bad(cmd, "a model file must be between 1 byte and 512 MiB"));
|
||||
}
|
||||
let sha256 = match known_sha {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_file(p).map_err(|e| fail(&format!("could not read {path}"), e))?,
|
||||
};
|
||||
let file_name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let suggestion = match onnx::probe_path(p) {
|
||||
Ok(info) => suggest_with(&info, &sha256, meta.len(), &file_name, extra),
|
||||
Err(e) => Suggestion::Unsupported(e.to_string()),
|
||||
};
|
||||
Ok(Inspection { sha256, size: meta.len(), file_name, suggestion })
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
refresh_catalog(s);
|
||||
let dir = s.face_models_dir.clone();
|
||||
let (settings, installed) = match &dir {
|
||||
Some(d) => (read_settings(d), installed_models(d)),
|
||||
None => (FaceSettings::default(), Vec::new()),
|
||||
};
|
||||
let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id);
|
||||
let mut models: Vec<Value> = Vec::new();
|
||||
for k in known::all() {
|
||||
let found = on_disk(&k.id);
|
||||
models.push(row(s, &k, found.is_some(), settings.embedder.as_deref() == Some(k.id.as_str()), found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
// the user's own catalog
|
||||
for e in &s.face_catalog.entries {
|
||||
let found = on_disk(&e.manifest.id);
|
||||
let chosen = settings.embedder.as_deref() == Some(e.manifest.id.as_str());
|
||||
models.push(row(s, &e.manifest, found.is_some(), chosen, found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
for i in &installed {
|
||||
let listed = known::all().iter().any(|k| k.id == i.manifest.id) || s.face_catalog.entries.iter().any(|e| e.manifest.id == i.manifest.id);
|
||||
if !listed {
|
||||
models.push(row(s, &i.manifest, true, settings.embedder.as_deref() == Some(i.manifest.id.as_str()), &i.accepted));
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"enabled": settings.enabled,
|
||||
"embedder": settings.embedder,
|
||||
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
|
||||
"runtime": cfg!(not(target_arch = "wasm32")),
|
||||
"models": models,
|
||||
// the user's own list of models to download: where it goes, how many it holds, and what was wrong with the rest
|
||||
"catalog": {
|
||||
"path": dir.as_ref().map(|d| d.join("catalog.json").display().to_string()),
|
||||
"models": s.face_catalog.entries.len(),
|
||||
"errors": s.face_catalog.errors,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.inspect";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
refresh_catalog(s);
|
||||
let ins = inspect_file(path, C, None, &catalog_manifests(s))?;
|
||||
let installed = s.face_models_dir.as_deref().map(installed_models).unwrap_or_default();
|
||||
let (kind, model, assumptions, reason) = match &ins.suggestion {
|
||||
Suggestion::Known(m) => ("known", Some(m), Vec::new(), None),
|
||||
Suggestion::Draft { manifest, assumptions } => ("draft", Some(manifest), assumptions.clone(), None),
|
||||
Suggestion::Unsupported(why) => ("unsupported", None, Vec::new(), Some(why.clone())),
|
||||
};
|
||||
let already = model.is_some_and(|m| installed.iter().any(|i| i.manifest.sha256 == m.sha256 && i.manifest.id == m.id));
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"fileName": ins.file_name,
|
||||
"sizeBytes": ins.size,
|
||||
"sha256": ins.sha256,
|
||||
"kind": kind,
|
||||
"model": model.map(|m| row(s, m, already, false, &Value::Null)),
|
||||
"assumptions": assumptions,
|
||||
"reason": reason,
|
||||
"alreadyInstalled": already,
|
||||
}))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.install";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let acknowledged = p.get("acknowledged").and_then(Value::as_bool).unwrap_or(false);
|
||||
let activate = p.get("activate").and_then(Value::as_bool).unwrap_or(true);
|
||||
refresh_catalog(s);
|
||||
let ins = inspect_file(path, C, None, &catalog_manifests(s))?;
|
||||
install_file(s, C, Path::new(path), ins, acknowledged, activate, false)
|
||||
}
|
||||
|
||||
/// Install the inspected model file `src`. A recognition model that passes its self-test becomes the one in use and
|
||||
/// recognition is switched on (`activate`, the default): installing a model is how someone says they want it.
|
||||
/// `verified` is for a download that was already checked against its recorded hash: it is moved into place instead
|
||||
/// of being copied and read again.
|
||||
fn install_file(s: &mut Session, c: &str, src: &Path, ins: Inspection, acknowledged: bool, activate: bool, verified: bool) -> Result<Value> {
|
||||
let dir = models_dir(s, c)?;
|
||||
let m = match ins.suggestion {
|
||||
Suggestion::Known(m) | Suggestion::Draft { manifest: m, .. } => m,
|
||||
Suggestion::Unsupported(why) => return Err(bad(c, format!("this model cannot be used yet: {why}"))),
|
||||
};
|
||||
if !matches!(m.role, Role::Embedder | Role::Detector) {
|
||||
return Err(bad(c, "only face detection and recognition models can be installed"));
|
||||
}
|
||||
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
|
||||
if !acknowledged {
|
||||
return Err(bad(c, "the licence has not been accepted: show the user its terms and pass `acknowledged: true` once they agree"));
|
||||
}
|
||||
let home = dir.join(&m.id);
|
||||
std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?;
|
||||
let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx"));
|
||||
let moved = verified && std::fs::rename(src, &part).is_ok();
|
||||
let placed = if moved {
|
||||
Ok(())
|
||||
} else {
|
||||
std::fs::copy(src, &part).map_err(|e| fail("could not copy the model (is the disk full?)", e)).and_then(|_| match sha256_file(&part) {
|
||||
Ok(h) if h == ins.sha256 => Ok(()),
|
||||
Ok(_) => Err(EngineError::Other("the copy does not match the original (the file changed while copying?)".into())),
|
||||
Err(e) => Err(fail("could not check the copy", e)),
|
||||
})
|
||||
};
|
||||
if let Err(e) = placed {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(e);
|
||||
}
|
||||
// Validate the candidate before replacing a working installation.
|
||||
let test = match self_test(&part, &m) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let _ = std::fs::remove_dir(&home);
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::rename(&part, &final_path) {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(fail("could not finish the copy", e));
|
||||
}
|
||||
write_atomic(&home.join("face-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?)?;
|
||||
let accepted = json!({"acceptedAt": (s.clock)(), "licence": m.licence.name, "commercial": m.licence.commercial, "fileName": ins.file_name, "selfTest": test});
|
||||
write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?)?;
|
||||
// a file that was downloaded and waited in the staging folder has served: the installed copy is the model now
|
||||
if src.parent() == Some(dir.join(face_download::STAGING).as_path()) {
|
||||
let _ = std::fs::remove_file(src);
|
||||
s.face_downloads.staged_file_used(src);
|
||||
}
|
||||
// the newest model is the one in use (the earlier one stays installed, and `faces.models.select` switches back)
|
||||
if activate && m.role == Role::Embedder {
|
||||
let mut st = read_settings(&dir);
|
||||
st.embedder = Some(m.id.clone());
|
||||
st.enabled = true;
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
let now = read_settings(&dir);
|
||||
let selected = now.embedder.as_deref() == Some(m.id.as_str());
|
||||
Ok(json!({"installed": row(s, &m, true, selected, &accepted), "active": {"embedder": now.embedder, "enabled": now.enabled}}))
|
||||
}
|
||||
|
||||
/// What an error says, without the command it came from (for a line shown to the user).
|
||||
fn plain(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every download that has arrived: each was accepted when it was started, so it is installed, chosen and
|
||||
/// switched on without another question. Called by `faces.models.downloads` and by every frame's `faces.pump`.
|
||||
pub(super) fn finish_downloads(s: &mut Session) {
|
||||
const C: &str = "faces.models.download";
|
||||
let extra = catalog_manifests(s);
|
||||
for (id, path, sha) in s.face_downloads.finished() {
|
||||
let outcome = inspect_file(&path.to_string_lossy(), C, Some(&sha), &extra).and_then(|ins| install_file(s, C, &path, ins, true, true, true));
|
||||
match outcome {
|
||||
Ok(_) => s.face_downloads.set_outcome(&id, State::Installed),
|
||||
Err(e) => {
|
||||
// a file that cannot be installed is of no use to anyone: throw it away, say why
|
||||
let _ = std::fs::remove_file(&path);
|
||||
s.face_downloads.set_outcome(&id, State::Failed(plain(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.models.download {id, acknowledged: true}`: fetch a model LightCraft knows an address for, in the background;
|
||||
/// when it has arrived and checked out it is installed.
|
||||
fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.download";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
refresh_catalog(s);
|
||||
let spec = download_spec(s, id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(bad(C, "the web build downloads no models"));
|
||||
}
|
||||
if p.get("acknowledged").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(bad(
|
||||
C,
|
||||
"the licence has not been accepted: show the user the model's terms (see `faces.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
if installed_models(&dir).iter().any(|i| i.manifest.id == spec.id) {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = spec.host().to_string();
|
||||
s.face_downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
fn download_row(s: &Session, id: &str, state: &State) -> Value {
|
||||
let from = download_spec(s, id).map(|d| d.host().to_string());
|
||||
match state {
|
||||
State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}),
|
||||
State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}),
|
||||
State::Installed => json!({"id": id, "state": "installed", "from": from}),
|
||||
State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}),
|
||||
State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}),
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.models.downloads`: where each download stands (a finished one is installed first).
|
||||
fn downloads(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
finish_downloads(s);
|
||||
let all: Vec<Value> = s.face_downloads.snapshot().iter().map(|(id, st)| download_row(s, id, st)).collect();
|
||||
Ok(json!({"running": s.face_downloads.running(), "downloads": all}))
|
||||
}
|
||||
|
||||
/// `faces.models.downloadCancel {id}`: stop a download, or clear a finished one (a staged file is deleted).
|
||||
fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.downloadCancel";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
Ok(json!({"discarded": s.face_downloads.discard(id)}))
|
||||
}
|
||||
|
||||
/// Load the model and run its self-test: `Ok(null)` in a build without the recognition runtime (nothing can
|
||||
/// be checked), `Ok(result)` when it passes, `Err(reason)` when it does not load or fails a check.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn self_test(path: &Path, m: &ModelManifest) -> std::result::Result<Value, String> {
|
||||
if m.role == Role::Detector {
|
||||
let bytes = read_capped(path, 8 << 20).ok_or("the detector cannot be read within its 8 MiB limit")?;
|
||||
let detector = lightcraft_faces::yunet::Detector::new(&bytes).map_err(|e| e.to_string())?;
|
||||
if Some(detector.input_side()) != usize::try_from(m.input.width).ok()
|
||||
|| m.input.width != m.input.height
|
||||
|| !matches!(&m.output, manifest::OutputSpec::Detector { decoder } if decoder == "yunet-v2")
|
||||
{
|
||||
return Err("unsupported detector input or output decoder".into());
|
||||
}
|
||||
detector.detect(&[0u8; 12], 2, 2, &lightcraft_faces::yunet::Options::default()).map_err(|e| e.to_string())?;
|
||||
return Ok(json!({"ok": true}));
|
||||
}
|
||||
let embedder = lightcraft_faces::runtime::Embedder::load(path, m).map_err(|e| e.to_string())?;
|
||||
let t = embedder.self_test();
|
||||
if !t.ok {
|
||||
let failed: Vec<&str> = t.checks.iter().filter(|(_, ok)| !*ok).map(|(c, _)| c.as_str()).collect();
|
||||
return Err(format!("the model failed its self-test: it does not {}", failed.join(", and does not ")));
|
||||
}
|
||||
serde_json::to_value(&t).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn self_test(_: &Path, _: &ModelManifest) -> std::result::Result<Value, String> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `faces.models.test {id}`: run an installed model's self-test again.
|
||||
fn test(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.test";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !cfg!(not(target_arch = "wasm32")) {
|
||||
return Err(bad(C, "this build cannot run face recognition models"));
|
||||
}
|
||||
let found = installed_models(&dir).into_iter().find(|i| i.manifest.id == id).ok_or_else(|| bad(C, "that model is not installed"))?;
|
||||
if found.manifest.role != Role::Embedder {
|
||||
return Err(bad(C, "only recognition models have a self-test"));
|
||||
}
|
||||
let result = self_test(&dir.join(id).join("model.onnx"), &found.manifest);
|
||||
let (ok, detail) = match &result {
|
||||
Ok(v) => (true, v.clone()),
|
||||
Err(e) => (false, json!(e)),
|
||||
};
|
||||
// keep the latest outcome with the model's record
|
||||
let mut record = found.accepted;
|
||||
if let Some(o) = record.as_object_mut() {
|
||||
o.insert("selfTest".into(), if ok { detail.clone() } else { json!({"ok": false, "error": detail}) });
|
||||
write_atomic(&dir.join(id).join("installed.json"), &serde_json::to_vec_pretty(&record).map_err(|e| fail("record", e))?)?;
|
||||
}
|
||||
Ok(json!({"id": id, "ok": ok, "result": detail}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.remove";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !manifest::valid_id(id) {
|
||||
return Err(bad(C, "not a model id"));
|
||||
}
|
||||
let home = dir.join(id);
|
||||
// only a folder that is a model (it has a manifest) is ever deleted
|
||||
if !home.join("face-model.json").is_file() {
|
||||
return Err(bad(C, "that model is not installed"));
|
||||
}
|
||||
std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if st.embedder.as_deref() == Some(id) {
|
||||
// another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen
|
||||
st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id);
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
Ok(json!({"removed": id, "embedder": st.embedder}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.select";
|
||||
let dir = models_dir(s, C)?;
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()),
|
||||
};
|
||||
if let Some(id) = &id
|
||||
&& !installed_models(&dir).iter().any(|i| &i.manifest.id == id && i.manifest.role == Role::Embedder)
|
||||
{
|
||||
return Err(bad(C, "that recognition model is not installed"));
|
||||
}
|
||||
let mut st = read_settings(&dir);
|
||||
st.embedder = id;
|
||||
write_settings(&dir, &st)?;
|
||||
Ok(json!({"embedder": st.embedder}))
|
||||
}
|
||||
|
||||
fn enable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.enable";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st = read_settings(&dir);
|
||||
st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
write_settings(&dir, &st)?;
|
||||
// the background scan notices at once, not within the second it trusts its last look
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.faces.enabled_seen = None;
|
||||
}
|
||||
Ok(json!({"enabled": st.enabled}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "faces.models.list", "Face Models", [], None, "{} → {dir, enabled, embedder, runtime, models: [{id, name, role, licence{name, commercial, url, notice}, provenance, source, sizeBytes, known, installed, selected}]}", always, list),
|
||||
cmd!(query "faces.models.inspect", "Inspect Face Model File", [], None, "{path} → what a .onnx file is: {kind: known | draft | unsupported, model, assumptions, reason, alreadyInstalled}; installs nothing", always, inspect),
|
||||
cmd!(query "faces.models.install", "Install Face Model", [], None, "{path, acknowledged: true, activate?: true} → {installed, active: {embedder, enabled}} — copy a .onnx face recognition model into the models folder. `acknowledged` must be true: the user has been shown its licence (see inspect) and accepted it. Unless `activate` is false the model becomes the one in use and recognition is switched on; an earlier model stays installed", always, install),
|
||||
cmd!(query "faces.models.download", "Download Face Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a recognition model LightCraft has a pinned address for (see `downloadHost` in the list), in the background over pure-Rust https (lightcraft-fetch). `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, then installed, chosen and switched on by itself; `faces.models.downloads` shows how far it is", always, download),
|
||||
cmd!(query "faces.models.downloads", "Face Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived; `installed` stays listed until `faces.models.downloadCancel` clears it", always, downloads),
|
||||
cmd!(query "faces.models.downloadCancel", "Cancel Face Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel),
|
||||
cmd!(query "faces.models.test", "Test Face Model", [], None, "{id} → {ok, result: {loadMs, embedMs, dimension, checks}} — load an installed recognition model and check it gives sensible faces; needs the recognition runtime", always, test),
|
||||
cmd!(query "faces.models.remove", "Remove Face Model", [], None, "{id} — delete an installed model", always, remove),
|
||||
cmd!(query "faces.models.select", "Choose Face Recognition Model", [], None, "{id: installed recogniser | null}", always, select),
|
||||
cmd!(query "faces.enable", "Face Recognition On/Off", [], None, "{enabled?: bool} (toggles when omitted)", always, enable),
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -119,6 +119,14 @@ fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
|
||||
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
|
||||
}
|
||||
|
||||
/// An optional album id: absent or `null` is `None`, anything but a number is an error.
|
||||
fn opt_album_param(p: &Value, key: &str, c: &str) -> Result<Option<AlbumId>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
|
||||
}
|
||||
@@ -784,7 +792,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
@@ -880,6 +888,80 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Move Album", Op::MoveAlbum { id, parent })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
"album.reorder",
|
||||
"Reorder Album",
|
||||
[],
|
||||
None,
|
||||
"{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.reorder")?;
|
||||
let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?;
|
||||
let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent };
|
||||
let before = opt_album_param(p, "before", "album.reorder")?;
|
||||
if before == Some(id) {
|
||||
// before itself: where it is
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let mut order: Vec<AlbumId> = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect();
|
||||
let at = match before {
|
||||
Some(b) => {
|
||||
let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?;
|
||||
if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) {
|
||||
return Err(bad("album.reorder", "folders are placed among folders and albums among albums"));
|
||||
}
|
||||
at
|
||||
}
|
||||
None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(),
|
||||
None => order.len(),
|
||||
};
|
||||
order.insert(at.min(order.len()), id);
|
||||
let moved = me.parent != parent;
|
||||
let mut ops = Vec::new();
|
||||
if moved {
|
||||
ops.push(Op::MoveAlbum { id, parent });
|
||||
}
|
||||
for (i, aid) in order.iter().enumerate() {
|
||||
let want = u32::try_from(i).ok();
|
||||
// a moved album starts without a place in its new folder
|
||||
let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) };
|
||||
if have != want {
|
||||
ops.push(Op::SetAlbumOrder { id: *aid, order: want });
|
||||
}
|
||||
}
|
||||
if ops.is_empty() {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let changed = ops.len();
|
||||
s.commit("Reorder Album", Op::Batch { ops })?;
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"album.sort",
|
||||
"Sort Albums by Name",
|
||||
[],
|
||||
None,
|
||||
"{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place",
|
||||
always,
|
||||
|s, p| {
|
||||
let parent = opt_album_param(p, "parent", "album.sort")?;
|
||||
let ops: Vec<Op> = s
|
||||
.catalog
|
||||
.album_children(parent)
|
||||
.into_iter()
|
||||
.filter(|a| a.order.is_some())
|
||||
.map(|a| Op::SetAlbumOrder { id: a.id, order: None })
|
||||
.collect();
|
||||
if ops.is_empty() {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let changed = ops.len();
|
||||
s.commit("Sort Albums by Name", Op::Batch { ops })?;
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
),
|
||||
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
@@ -1062,6 +1144,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
// ---- import
|
||||
cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None,
|
||||
"{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required",
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
"Import Lightroom Catalog",
|
||||
[],
|
||||
None,
|
||||
"{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?;
|
||||
crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "library.importPreview",
|
||||
"Review Import",
|
||||
|
||||
@@ -41,22 +41,11 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let ep = e.path();
|
||||
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
if ep.is_dir() {
|
||||
stack.push(ep);
|
||||
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
|
||||
&& let Ok(t) = std::fs::read_to_string(&ep)
|
||||
{
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
}
|
||||
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
|
||||
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
|
||||
if let Ok(t) = std::fs::read_to_string(&ep) {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
|
||||
@@ -432,6 +432,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"available": crate::segment::Segmenter::AVAILABLE,
|
||||
"installed": g.installed(),
|
||||
"dir": g.dir.as_ref().map(|d| d.display().to_string()),
|
||||
"remote": g.remote_endpoint(),
|
||||
"loaded": g.loaded(),
|
||||
"busy": g.busy(),
|
||||
"analyzing": g.analyzing(),
|
||||
|
||||
@@ -10,9 +10,13 @@ mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
pub(crate) mod denoise;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
@@ -140,6 +144,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(missing::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
v.extend(face_models::specs());
|
||||
v.extend(face_detect::specs());
|
||||
v.extend(face_recognize::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera
|
||||
//! profiles, face and denoise models).
|
||||
//!
|
||||
//! 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- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
|
||||
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")))
|
||||
}
|
||||
|
||||
/// Where opt-in denoise models are kept: `$LIGHTCRAFT_DENOISE_MODELS` if set, else `<config>/denoise-models`.
|
||||
pub fn default_denoise_models_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("LIGHTCRAFT_DENOISE_MODELS")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| config_dir().map(|d| d.join("denoise-models")))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Keep face models in the shared default folder ([`default_face_models_dir`]).
|
||||
pub fn with_default_face_models(mut self) -> Self {
|
||||
self.face_models_dir = default_face_models_dir();
|
||||
self
|
||||
}
|
||||
|
||||
/// Keep denoise models in the shared default folder ([`default_denoise_models_dir`]).
|
||||
pub fn with_default_denoise_models(mut self) -> Self {
|
||||
self.set_denoise_models_dir(default_denoise_models_dir());
|
||||
self
|
||||
}
|
||||
|
||||
/// Point AI denoise at `dir` (or at no folder), dropping what was learned about the old one.
|
||||
pub fn set_denoise_models_dir(&mut self, dir: Option<PathBuf>) {
|
||||
self.denoise.models_dir = dir;
|
||||
self.denoise.touch();
|
||||
}
|
||||
}
|
||||
+97
-10
@@ -44,6 +44,17 @@ const NON_ADJUSTMENT: &[&str] = &[
|
||||
"crs:AlreadyApplied",
|
||||
"crs:RawFileName",
|
||||
"crs:HasCrop",
|
||||
// preset/library bookkeeping and tool options that don't change the rendering
|
||||
"crs:Cluster",
|
||||
"crs:SupportsAmount2",
|
||||
"crs:RequiresRGBTables",
|
||||
"crs:SortName",
|
||||
"crs:Description",
|
||||
"crs:CropConstrainToWarp",
|
||||
"crs:OverrideLookVignette",
|
||||
// the file's as-shot white, recorded next to the edit (ours comes from the file itself)
|
||||
"crs:AsShotTemperature",
|
||||
"crs:AsShotTint",
|
||||
];
|
||||
|
||||
/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
|
||||
@@ -233,11 +244,14 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
put(o, "curve.master", c);
|
||||
}
|
||||
}
|
||||
for (crs, ch) in
|
||||
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
|
||||
{
|
||||
if let Some(c) = curve(props, &format!("crs:{crs}")) {
|
||||
put(o, &format!("curve.{ch}"), c);
|
||||
// Lightroom Classic reads the red / green / blue curves only together with the master 2012
|
||||
// curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged
|
||||
if let Some(c) = curve(props, "crs:ToneCurvePV2012") {
|
||||
put(o, "curve.master", c);
|
||||
for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] {
|
||||
if let Some(c) = curve(props, &format!("crs:{crs}")) {
|
||||
put(o, &format!("curve.{ch}"), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +301,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
n(o, "GrainSize", "grain.size");
|
||||
n(o, "GrainFrequency", "grain.roughness");
|
||||
|
||||
// ---- Optics (manual corrections; lens profiles are ours, only the switch carries over)
|
||||
if let Some(b) = boolean(props, "crs:LensProfileEnable") {
|
||||
put(o, "optics.lens_profile", json!(b));
|
||||
// ---- Optics (manual corrections; lens profiles are ours, only the switch carries over).
|
||||
// Our profile corrections are the file's own (DNG-embedded) ones, which Lightroom applies
|
||||
// whatever its "Enable Profile Corrections" box says [inferred]: so only an enabling switch
|
||||
// carries over, and `LensProfileEnable=0` (the default in most presets) leaves ours as it is.
|
||||
if boolean(props, "crs:LensProfileEnable") == Some(true) {
|
||||
put(o, "optics.lens_profile", json!(true));
|
||||
}
|
||||
if let Some(b) = boolean(props, "crs:AutoLateralCA") {
|
||||
put(o, "optics.remove_ca", json!(b));
|
||||
@@ -373,8 +390,19 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves
|
||||
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
|
||||
// HDR edit mode that is off, and Point Color slots that are all empty (-1)
|
||||
let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v));
|
||||
let off = |v: &[String]| v.iter().all(|s| s.trim() == "0");
|
||||
let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::<f64>().is_ok_and(|x| x == -1.0) || n.trim().is_empty()));
|
||||
let quiet = |k: &str| {
|
||||
k.starts_with("Enable")
|
||||
|| k.starts_with("ToneCurveName")
|
||||
|| k == "AutoTone"
|
||||
|| k == "AutoGrayscaleMix"
|
||||
|| (k == "HDREditMode" && value_is(k, &off))
|
||||
|| (k == "PointColors" && value_is(k, &empty_points))
|
||||
};
|
||||
let mut unmapped: Vec<String> = props
|
||||
.keys()
|
||||
.filter(|k| k.starts_with("crs:"))
|
||||
@@ -539,4 +567,63 @@ mod tests {
|
||||
assert!(!p.builtin);
|
||||
assert!(preset_from_xmp("<x/>", "f").is_none());
|
||||
}
|
||||
|
||||
/// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry.
|
||||
fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:PresetType="Normal" crs:Cluster="" crs:UUID="0123ABCD" crs:SupportsAmount2="True" crs:SupportsAmount="True"
|
||||
crs:RequiresRGBTables="False" crs:Contrast2012="+25" crs:LensProfileEnable="0" crs:OverrideLookVignette="False"
|
||||
crs:CropConstrainToWarp="0" crs:AsShotTemperature="5500" crs:AsShotTint="10" {extra_attrs}>
|
||||
<crs:SortName><rdf:Alt><rdf:li xml:lang="x-default"/></rdf:Alt></crs:SortName>
|
||||
<crs:Description><rdf:Alt><rdf:li xml:lang="x-default"/></rdf:Alt></crs:Description>
|
||||
{extra_elems}
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_curves_need_the_master_curve() {
|
||||
// Lightroom ignores a red / green / blue curve without `ToneCurvePV2012`
|
||||
let packet = |master: &str| {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{master}
|
||||
<crs:ToneCurvePV2012Green><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012Green>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
};
|
||||
let alone = to_partial(&props(&packet("")), Some(true));
|
||||
assert!(alone.pointer("/curve/green").is_none(), "{alone}");
|
||||
let master = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
|
||||
let with = to_partial(&props(&packet(master)), Some(true));
|
||||
assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}");
|
||||
}
|
||||
|
||||
fn unmapped(x: &str) -> Vec<String> {
|
||||
to_partial_report(&props(x), None, None, 1.5).1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_bookkeeping_is_not_reported_but_real_gaps_are() {
|
||||
let empty_points = "<crs:PointColors><rdf:Seq><rdf:li>-1.000000, -1.000000, -1.000000, -1.000000</rdf:li></rdf:Seq></crs:PointColors>";
|
||||
assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::<String>::new());
|
||||
// an HDR edit, a used Point Color slot and an unknown adjustment still are
|
||||
let used_points = "<crs:PointColors><rdf:Seq><rdf:li>0.5, 0.2, 0.1, 10, 0, 0, 0, 0</rdf:li></rdf:Seq></crs:PointColors>";
|
||||
let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points));
|
||||
assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_lens_profile_switch_keeps_embedded_corrections() {
|
||||
// Lightroom applies a file's built-in lens corrections regardless of its profile switch,
|
||||
// and ours are only those: a preset's `LensProfileEnable=0` must not turn them off
|
||||
let p = props(&preset_packet("", ""));
|
||||
let mut d = DevelopSettings::default();
|
||||
d.optics.lens_profile = true;
|
||||
assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile);
|
||||
let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#));
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile);
|
||||
}
|
||||
}
|
||||
+154
-2
@@ -62,7 +62,17 @@ pub fn load(s: &mut Session) {
|
||||
}
|
||||
let folder = s.catalog.alloc_album_id();
|
||||
ops.push(Op::AddAlbum {
|
||||
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
album: Album {
|
||||
id: folder,
|
||||
name: "Travel 2026".into(),
|
||||
parent: None,
|
||||
folder: true,
|
||||
photos: vec![],
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
});
|
||||
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
|
||||
for (name, parent, photos) in [
|
||||
@@ -75,7 +85,9 @@ pub fn load(s: &mut Session) {
|
||||
] {
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } });
|
||||
ops.push(Op::AddAlbum {
|
||||
album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false, order: None },
|
||||
});
|
||||
}
|
||||
for op in ops {
|
||||
let _ = s.catalog.apply(op);
|
||||
@@ -84,3 +96,143 @@ pub fn load(s: &mut Session) {
|
||||
s.selection = crate::Selection::single(*first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Procedural scenes are deterministic but costly to evaluate: every pixel runs the scene's noise
|
||||
/// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI
|
||||
/// VM). Each scene at each size is therefore generated once per process and shared:
|
||||
///
|
||||
/// - concurrent requests wait for one generation (Before and After at 1:1 both load the
|
||||
/// original; sessions side by side, e.g. parallel tests, open the same photo);
|
||||
/// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same
|
||||
/// allocations the sessions' source caches hold, so they cost extra memory only once no session
|
||||
/// holds them any more.
|
||||
///
|
||||
/// Only demo photos use this; real files are decoded by their loader as before.
|
||||
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc<lightcraft_raster::Rgb32f> {
|
||||
let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge };
|
||||
let slot = {
|
||||
let mut c = scene_cache();
|
||||
c.tick += 1;
|
||||
let tick = c.tick;
|
||||
match c.entries.iter_mut().find(|e| e.key == key) {
|
||||
Some(e) => {
|
||||
e.used = tick;
|
||||
e.slot.clone()
|
||||
}
|
||||
None => {
|
||||
let slot = Arc::new(std::sync::OnceLock::new());
|
||||
c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick });
|
||||
slot
|
||||
}
|
||||
}
|
||||
};
|
||||
// (no lock held while generating: other scenes and sizes are served meanwhile)
|
||||
let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone();
|
||||
trim_scene_cache(&mut scene_cache(), scene_cache_limit());
|
||||
pixels
|
||||
}
|
||||
|
||||
/// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough
|
||||
/// for one 24 MP original and the previews around it).
|
||||
fn scene_cache_limit() -> usize {
|
||||
crate::memory::budget() / 2
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
struct SceneKey {
|
||||
kind: lightcraft_scenes::Kind,
|
||||
seed: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
max_edge: usize,
|
||||
}
|
||||
|
||||
type SceneSlot = Arc<std::sync::OnceLock<Arc<lightcraft_raster::Rgb32f>>>;
|
||||
|
||||
struct SceneEntry {
|
||||
key: SceneKey,
|
||||
slot: SceneSlot,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SceneCache {
|
||||
entries: Vec<SceneEntry>,
|
||||
tick: u64,
|
||||
}
|
||||
|
||||
fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> {
|
||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<SceneCache>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(Default::default).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn scene_bytes(slot: &SceneSlot) -> usize {
|
||||
slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
|
||||
}
|
||||
|
||||
/// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still
|
||||
/// being generated are kept (their waiters hold the slot anyway).
|
||||
fn trim_scene_cache(c: &mut SceneCache, limit: usize) {
|
||||
loop {
|
||||
let total: usize = c.entries.iter().map(|e| scene_bytes(&e.slot)).fold(0, usize::saturating_add);
|
||||
if total <= limit {
|
||||
return;
|
||||
}
|
||||
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
|
||||
return;
|
||||
};
|
||||
c.entries.swap_remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn small_scene() -> lightcraft_scenes::Scene {
|
||||
lightcraft_scenes::demo_library().swap_remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scene_is_generated_once_per_size_and_shared() {
|
||||
let s = small_scene();
|
||||
let a = scene_pixels(&s, 48);
|
||||
let b = scene_pixels(&s, 48);
|
||||
assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation");
|
||||
assert_eq!(a.width.max(a.height), 48);
|
||||
assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly");
|
||||
let c = scene_pixels(&s, 40);
|
||||
assert_eq!(c.width.max(c.height), 40, "another size is its own generation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_requests_wait_for_one_generation() {
|
||||
let s = small_scene();
|
||||
let edge = 37; // a size no other test asks for
|
||||
let got: Vec<_> = std::thread::scope(|sc| {
|
||||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect();
|
||||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_keeps_the_most_recent_scenes_within_its_limit() {
|
||||
let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge };
|
||||
let entry = |max_edge, used, px: Option<usize>| {
|
||||
let slot: SceneSlot = Arc::new(std::sync::OnceLock::new());
|
||||
if let Some(n) = px {
|
||||
let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
|
||||
}
|
||||
SceneEntry { key: key(max_edge), slot, used }
|
||||
};
|
||||
let px = std::mem::size_of::<[f32; 3]>();
|
||||
let mut c = SceneCache { entries: vec![entry(1, 3, Some(10)), entry(2, 1, Some(10)), entry(3, 0, None), entry(4, 2, Some(10))], tick: 3 };
|
||||
trim_scene_cache(&mut c, 25 * px);
|
||||
let mut kept: Vec<usize> = c.entries.iter().map(|e| e.key.max_edge).collect();
|
||||
kept.sort_unstable();
|
||||
assert_eq!(kept, vec![1, 3, 4], "the least recently used finished scene went; the one in progress stays");
|
||||
trim_scene_cache(&mut c, 0);
|
||||
assert_eq!(c.entries.iter().map(|e| e.key.max_edge).collect::<Vec<_>>(), vec![3], "only the scene in progress is left");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -191,7 +191,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
|
||||
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
|
||||
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
|
||||
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
pub fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
|
||||
return Some(p);
|
||||
}
|
||||
|
||||
+91
-25
@@ -258,33 +258,41 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
|
||||
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
|
||||
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
|
||||
fn vertical_watermark_glyph(
|
||||
fn watermark_cell_glyphs(
|
||||
font: &ab_glyph::FontRef<'_>,
|
||||
data: &harfrust::ShaperData,
|
||||
ch: char,
|
||||
text: &str,
|
||||
vertical: bool,
|
||||
px: f32,
|
||||
left: f32,
|
||||
top: f32,
|
||||
) -> Option<ab_glyph::Glyph> {
|
||||
) -> Option<Vec<ab_glyph::Glyph>> {
|
||||
use ab_glyph::{Font, ScaleFont};
|
||||
let face = harfrust::FontRef::new(font.font_data()).ok()?;
|
||||
let mut buf = harfrust::UnicodeBuffer::new();
|
||||
buf.add(ch, 0);
|
||||
buf.set_direction(harfrust::Direction::TopToBottom);
|
||||
buf.push_str(text);
|
||||
buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
|
||||
if shaped.glyph_infos().len() != 1 {
|
||||
if shaped.glyph_infos().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?;
|
||||
let pos = shaped.glyph_positions().first()?;
|
||||
let sf = font.as_scaled(px);
|
||||
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
|
||||
let height = -(pos.y_advance as f32) * sy;
|
||||
let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy);
|
||||
Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen))
|
||||
let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>();
|
||||
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
|
||||
let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) };
|
||||
let mut glyphs = Vec::new();
|
||||
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
|
||||
let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?;
|
||||
let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy);
|
||||
glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen));
|
||||
x += pos.x_advance as f32 * sx;
|
||||
y -= pos.y_advance as f32 * sy;
|
||||
}
|
||||
Some(glyphs)
|
||||
}
|
||||
|
||||
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
|
||||
@@ -355,8 +363,13 @@ fn watermark_coverage(
|
||||
let mut prev = None;
|
||||
let columns = text.split('\n').count();
|
||||
let mut column = 0usize;
|
||||
for original in text.chars() {
|
||||
if original == '\n' {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
// Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes.
|
||||
let cells: Box<dyn Iterator<Item = &str>> =
|
||||
if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) };
|
||||
for cell in cells {
|
||||
let Some(original) = cell.chars().next() else { continue };
|
||||
if cell == "\n" || (wm.vertical && cell == "\r\n") {
|
||||
if wm.vertical {
|
||||
column = column.saturating_add(1);
|
||||
tw = tw.max(y);
|
||||
@@ -369,17 +382,20 @@ fn watermark_coverage(
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
if wm.vertical
|
||||
&& matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60)
|
||||
&& let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0)
|
||||
&& let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face))
|
||||
{
|
||||
let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60);
|
||||
if wm.vertical && (cjk || cell.chars().count() > 1) {
|
||||
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
|
||||
if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y)
|
||||
&& (glyph.id != font.glyph_id(original)
|
||||
|| !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】'))
|
||||
{
|
||||
glyphs.push((face, glyph, None));
|
||||
let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| {
|
||||
let data = data.as_ref()?;
|
||||
let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?;
|
||||
let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original));
|
||||
if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') {
|
||||
return None;
|
||||
}
|
||||
Some((face, cluster))
|
||||
});
|
||||
if let Some((face, cluster)) = shaped {
|
||||
glyphs.extend(cluster.into_iter().map(|g| (face, g, None)));
|
||||
y += px;
|
||||
continue;
|
||||
}
|
||||
@@ -1706,7 +1722,7 @@ mod tests {
|
||||
assert_eq!(font.glyph_id('︒').0, 0);
|
||||
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap());
|
||||
for ch in ['、', '。'] {
|
||||
let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap();
|
||||
let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0);
|
||||
assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}");
|
||||
let rect = font.outline_glyph(glyph).unwrap().px_bounds();
|
||||
assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}");
|
||||
@@ -1736,6 +1752,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_watermarks_keep_combining_marks_in_one_cell() {
|
||||
let coverage = |text: &str, craft| {
|
||||
let wm = Watermark {
|
||||
text: text.into(),
|
||||
vertical: true,
|
||||
size: 0.1,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
shadow: false,
|
||||
opacity: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pixels = vec![0.0f32; 400 * 300];
|
||||
watermark_coverage(400, 300, &wm, craft, |x, y, k, _| pixels[y * 400 + x] = k);
|
||||
assert!(pixels.iter().any(|&k| k > 0.0));
|
||||
pixels
|
||||
};
|
||||
let compare = |a: &str, b: &str, craft| {
|
||||
let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft));
|
||||
let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b);
|
||||
assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}");
|
||||
};
|
||||
for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] {
|
||||
compare(composed, decomposed, &[]);
|
||||
}
|
||||
if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") {
|
||||
for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] {
|
||||
compare(composed, decomposed, crate::fonts::CRAFT_FONTS);
|
||||
}
|
||||
} else {
|
||||
eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_watermark_cell_retains_multiple_glyphs() {
|
||||
use ab_glyph::{Font, ScaleFont};
|
||||
let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap();
|
||||
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap());
|
||||
// There is no precomposed A with a combining long solidus overlay.
|
||||
let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap();
|
||||
assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph");
|
||||
let baseline = font.as_scaled(30.0).ascent();
|
||||
for glyph in glyphs {
|
||||
assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark");
|
||||
assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_watermarks_support_vertical_columns() {
|
||||
use ab_glyph::Font;
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Downloading a face model the user asked for (Settings ▸ Faces ▸ Download).
|
||||
//!
|
||||
//! The transfer is [`lightcraft_fetch`] (pure-Rust HTTPS with resume, size and SHA-256 checks), on a background thread,
|
||||
//! so the UI never waits for the network. Only a model listed in [`lightcraft_faces::known::download`] can be fetched:
|
||||
//! the address is a pinned constant, never something typed or passed in, and no token is ever sent with it. What arrives
|
||||
//! matches the model's recorded size and SHA-256 or it is thrown away; it then waits in a staging folder until the model
|
||||
//! is installed (`faces.models.install`, or by itself once the user has accepted the model's terms in the download).
|
||||
//!
|
||||
//! Failure is a message, never a panic: no network, a stalled or truncated transfer, a full disk, a file that is not the
|
||||
//! one expected, and a panic inside the thread itself all end as [`State::Failed`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_faces::known::Download;
|
||||
|
||||
/// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it
|
||||
/// can never be a model's own folder (ids start with a letter or digit).
|
||||
pub const STAGING: &str = ".downloads";
|
||||
|
||||
/// Where a download stands.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
/// Fetching (`bytes` of `total` so far); the file is checked as the last step.
|
||||
Running {
|
||||
bytes: u64,
|
||||
total: u64,
|
||||
},
|
||||
/// Fetched and verified (its SHA-256 is `sha256`), waiting in the staging folder at `path` to be installed.
|
||||
Done {
|
||||
path: PathBuf,
|
||||
sha256: String,
|
||||
},
|
||||
/// Installed and in use: nothing more to do but tell the user.
|
||||
Installed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
cancel: AtomicBool,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn new(total: u64) -> Self {
|
||||
Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) }
|
||||
}
|
||||
|
||||
fn set(&self, s: State) {
|
||||
*self.state.lock().unwrap_or_else(PoisonError::into_inner) = s;
|
||||
}
|
||||
|
||||
fn get(&self) -> State {
|
||||
self.state.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The downloads of one session, by model id.
|
||||
#[derive(Default)]
|
||||
pub struct Downloads {
|
||||
jobs: HashMap<String, Arc<Shared>>,
|
||||
}
|
||||
|
||||
impl Downloads {
|
||||
/// Start fetching `spec` into `<models dir>/.downloads/`. Different models can download side by side; the same
|
||||
/// one is started once.
|
||||
pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> {
|
||||
if self.jobs.get(spec.id.as_str()).is_some_and(|j| matches!(j.get(), State::Running { .. })) {
|
||||
return Err("that model is already downloading".into());
|
||||
}
|
||||
let staging = models_dir.join(STAGING);
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?;
|
||||
let shared = Arc::new(Shared::new(spec.size_bytes));
|
||||
let worker = shared.clone();
|
||||
let id = spec.id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("face-model-download".into())
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker)));
|
||||
if outcome.is_err() {
|
||||
worker.set(State::Failed("the download stopped unexpectedly".into()));
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("could not start the download: {e}"))?;
|
||||
self.jobs.insert(id, shared);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A download that has arrived and been checked, as if the transfer had just ended (tests of installing).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn arrived(&mut self, id: &str, path: PathBuf, sha256: &str) {
|
||||
let j = Shared::new(0);
|
||||
j.set(State::Done { path, sha256: sha256.to_string() });
|
||||
self.jobs.insert(id.to_string(), Arc::new(j));
|
||||
}
|
||||
|
||||
/// Every download this session has started and not discarded, by model id.
|
||||
pub fn snapshot(&self) -> Vec<(String, State)> {
|
||||
let mut all: Vec<_> = self.jobs.iter().map(|(id, j)| (id.clone(), j.get())).filter(|(_, s)| *s != State::Cancelled).collect();
|
||||
all.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
all
|
||||
}
|
||||
|
||||
pub fn running(&self) -> bool {
|
||||
self.jobs.values().any(|j| matches!(j.get(), State::Running { .. }))
|
||||
}
|
||||
|
||||
/// Stop a running download, or discard a finished one (its staged file is deleted). `true` if there was one.
|
||||
pub fn discard(&mut self, id: &str) -> bool {
|
||||
let Some(job) = self.jobs.get(id) else { return false };
|
||||
job.cancel.store(true, Ordering::Relaxed);
|
||||
match job.get() {
|
||||
// the thread notices, deletes the partial file and marks itself cancelled
|
||||
State::Running { .. } => {}
|
||||
State::Done { path, .. } => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
self.jobs.remove(id);
|
||||
}
|
||||
State::Installed | State::Failed(_) | State::Cancelled => {
|
||||
self.jobs.remove(id);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The staged file at `path` has been installed (its copy is the model now) or is no longer wanted.
|
||||
pub fn staged_file_used(&mut self, path: &Path) {
|
||||
self.jobs.retain(|_, j| !matches!(j.get(), State::Done { path: p, .. } if p == path));
|
||||
}
|
||||
|
||||
/// Downloads that have arrived and been checked, ready to install: (model id, staged file, its SHA-256).
|
||||
pub fn finished(&self) -> Vec<(String, PathBuf, String)> {
|
||||
let mut out: Vec<_> = self
|
||||
.jobs
|
||||
.iter()
|
||||
.filter_map(|(id, j)| match j.get() {
|
||||
State::Done { path, sha256 } => Some((id.clone(), path, sha256)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// Record how installing a finished download went (`Installed` or `Failed`).
|
||||
pub fn set_outcome(&mut self, id: &str, state: State) {
|
||||
match self.jobs.get(id) {
|
||||
Some(j) => j.set(state),
|
||||
None => {
|
||||
let j = Shared::new(0);
|
||||
j.set(state);
|
||||
self.jobs.insert(id.to_string(), Arc::new(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Downloads {
|
||||
/// Quitting while a download runs stops it instead of leaving it behind.
|
||||
fn drop(&mut self) {
|
||||
for job in self.jobs.values() {
|
||||
job.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
while self.running() && started.elapsed() < Duration::from_secs(1) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What went wrong, in words the user can act on.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn explain(e: &lightcraft_fetch::DownloadError) -> String {
|
||||
use lightcraft_fetch::DownloadError;
|
||||
match e {
|
||||
DownloadError::AllMirrorsFailed { errors, .. } => {
|
||||
let detail: String = errors.join("; ").chars().take(300).collect();
|
||||
format!("could not download the model ({detail}): check the internet connection, or get the file from its page and add it yourself")
|
||||
}
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `spec` from `mirror` (the address without the file name) into `staging`, check it and leave it there.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fetch(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) -> Result<(), lightcraft_fetch::DownloadError> {
|
||||
let file = lightcraft_fetch::FileSpec { name: &spec.file_name, size: Some(spec.size_bytes), sha256: Some(&spec.sha256), max: spec.size_bytes };
|
||||
lightcraft_fetch::download(&[file], &[mirror.to_string()], staging, &lightcraft_fetch::Options::default(), &shared.cancel, &mut |p| {
|
||||
shared.set(State::Running { bytes: p.done.min(spec.size_bytes), total: spec.size_bytes });
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> {
|
||||
Err("the web build downloads no models".into())
|
||||
}
|
||||
|
||||
/// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`].
|
||||
fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) {
|
||||
let target = staging.join(&spec.file_name);
|
||||
let part = staging.join(format!("{}.part", spec.file_name));
|
||||
match fetch(spec, mirror, staging, shared) {
|
||||
Ok(()) => shared.set(State::Done { path: target, sha256: spec.sha256.to_string() }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Err(lightcraft_fetch::DownloadError::Cancelled) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
shared.set(State::Cancelled);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
shared.set(State::Failed(explain(&e)));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
shared.set(State::Failed(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_faces::hash::sha256_hex;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-face-dl-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn spec_for(bytes: &[u8]) -> Download {
|
||||
Download {
|
||||
id: "test-model".into(),
|
||||
url: "https://example.invalid/m/test-model.onnx".into(),
|
||||
file_name: "test-model.onnx".into(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
sha256: sha256_hex(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// A local server that answers every request with `status` and `body` (the whole file, no ranges).
|
||||
fn serve(status: &'static str, body: Vec<u8>) -> String {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}/m", l.local_addr().unwrap());
|
||||
std::thread::spawn(move || {
|
||||
for mut s in l.incoming().flatten() {
|
||||
let mut r = BufReader::new(s.try_clone().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = s.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());
|
||||
let _ = s.write_all(&body);
|
||||
}
|
||||
});
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_checks_and_stages_a_file() {
|
||||
let dir = scratch("ok");
|
||||
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let (staging, spec) = (dir.join(STAGING), spec_for(&bytes));
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
let mirror = serve("200 OK", bytes.clone());
|
||||
let shared = Shared::new(spec.size_bytes);
|
||||
run(&spec, &mirror, &staging, &shared);
|
||||
let State::Done { path, sha256 } = shared.get() else { panic!("{:?}", shared.get()) };
|
||||
assert_eq!((std::fs::read(&path).unwrap(), sha256), (bytes, spec.sha256.to_string()));
|
||||
assert!(!staging.join("test-model.onnx.part").exists());
|
||||
// asked again with the copy still staged: no fetch needed (nothing listens there)
|
||||
let again = Shared::new(spec.size_bytes);
|
||||
run(&spec, "http://127.0.0.1:1/m", &staging, &again);
|
||||
assert!(matches!(again.get(), State::Done { .. }), "{:?}", again.get());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_does_not_match_is_thrown_away() {
|
||||
let dir = scratch("bad");
|
||||
let staging = dir.join(STAGING);
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
// the right size and the wrong content
|
||||
let spec = spec_for(&[7u8; 5000]);
|
||||
let shared = Shared::new(spec.size_bytes);
|
||||
run(&spec, &serve("200 OK", vec![8u8; 5000]), &staging, &shared);
|
||||
assert!(matches!(shared.get(), State::Failed(_)), "{:?}", shared.get());
|
||||
// nothing is left behind: not the file, not a partial one
|
||||
assert_eq!(std::fs::read_dir(&staging).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_or_refusing_server_is_a_message() {
|
||||
let dir = scratch("down");
|
||||
let staging = dir.join(STAGING);
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
let spec = spec_for(b"abc");
|
||||
for mirror in ["http://127.0.0.1:1/m".to_string(), serve("404 Not Found", b"not found".to_vec())] {
|
||||
let shared = Shared::new(3);
|
||||
run(&spec, &mirror, &staging, &shared);
|
||||
let State::Failed(why) = shared.get() else { panic!("{:?}", shared.get()) };
|
||||
assert!(why.contains("internet connection"), "{why}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&staging).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_download_leaves_nothing() {
|
||||
let dir = scratch("cancel");
|
||||
let staging = dir.join(STAGING);
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
let spec = spec_for(b"abc");
|
||||
let shared = Shared::new(3);
|
||||
shared.cancel.store(true, Ordering::Relaxed);
|
||||
run(&spec, &serve("200 OK", b"abc".to_vec()), &staging, &shared);
|
||||
assert_eq!(shared.get(), State::Cancelled);
|
||||
assert_eq!(std::fs::read_dir(&staging).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_the_session_stops_a_running_download() {
|
||||
let shared = Arc::new(Shared::new(10));
|
||||
// a stand-in for the download thread: notices the cancel flag the way the downloader does
|
||||
let worker = shared.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
while !worker.cancel.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
worker.set(State::Cancelled);
|
||||
});
|
||||
let mut d = Downloads::default();
|
||||
d.jobs.insert("m".into(), shared.clone());
|
||||
assert!(d.running());
|
||||
let started = std::time::Instant::now();
|
||||
drop(d);
|
||||
assert!(started.elapsed() < Duration::from_millis(900), "quitting must not wait for a transfer to finish");
|
||||
assert_eq!(shared.get(), State::Cancelled);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarding_a_finished_download_deletes_its_file() {
|
||||
let dir = scratch("discard");
|
||||
let file = dir.join("staged.onnx");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
let mut d = Downloads::default();
|
||||
let shared = Arc::new(Shared::new(1));
|
||||
shared.set(State::Done { path: file.clone(), sha256: String::new() });
|
||||
d.jobs.insert("m".into(), shared);
|
||||
assert_eq!(d.snapshot().len(), 1);
|
||||
assert!(!d.running());
|
||||
assert!(d.discard("m"));
|
||||
assert!(!file.exists());
|
||||
assert!(d.snapshot().is_empty());
|
||||
assert!(!d.discard("m"), "nothing left to discard");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! The face index: one embedding per face region, kept in memory and cached beside the library.
|
||||
//!
|
||||
//! Embedding a face costs a model run (tens to hundreds of milliseconds), so each region is embedded once and
|
||||
//! remembered, keyed by its photo and its box. The cache file (`face-embeddings-<model id>.bin` in the library folder,
|
||||
//! one per model, so switching back to an earlier model does not start over) belongs to one model: its header names the
|
||||
//! model and its file hash, and a file made by another model, an older format, or a damaged one is simply ignored and
|
||||
//! rebuilt. Embeddings are kept out of the catalog on purpose: they are large, rebuildable, and not comparable between
|
||||
//! models.
|
||||
//!
|
||||
//! Each face also keeps the box to cut its picture from (its "view"): the detector's box when the detector found the same
|
||||
//! face, so every face is shown equally close however loosely or tightly its own region was drawn (a Lightroom box, an
|
||||
//! older tool's box), else the region's own box.
|
||||
//!
|
||||
//! Layout (little-endian): `"LCFE"`, version `2`, tag length `u16`, the tag (`<model id>@<sha256>`), dimension `u32`,
|
||||
//! then records of `photo u64`, four `i32` (the region's box in ten-thousandths), four `i32` (the view box likewise), and
|
||||
//! `dim` `f32`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_geom::Rect;
|
||||
|
||||
const MAGIC: &[u8; 4] = b"LCFE";
|
||||
const VERSION: u8 = 2;
|
||||
/// Largest cache file we read.
|
||||
const MAX_FILE: u64 = 1 << 30;
|
||||
|
||||
pub(crate) type Key = (u64, [i32; 4]);
|
||||
|
||||
/// One face's record: its embedding and its view box (quantized like a key).
|
||||
type Entry = (Arc<[f32]>, [i32; 4]);
|
||||
|
||||
/// A quantized box as a rectangle again.
|
||||
fn rect_of(k: [i32; 4]) -> Rect {
|
||||
let f = |v: i32| f64::from(v) / 10_000.0;
|
||||
Rect { x0: f(k[0]), y0: f(k[1]), x1: f(k[2]), y1: f(k[3]) }
|
||||
}
|
||||
|
||||
/// A region's box as the integer key it is cached under.
|
||||
pub(crate) fn region_key(r: &Rect) -> [i32; 4] {
|
||||
let q = |v: f64| if v.is_finite() { (v.clamp(-1e3, 1e3) * 10_000.0).round() as i32 } else { 0 };
|
||||
[q(r.x0), q(r.y0), q(r.x1), q(r.y1)]
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Index {
|
||||
/// `<model id>@<sha256>` of the model the embeddings belong to.
|
||||
pub tag: String,
|
||||
pub dim: usize,
|
||||
entries: HashMap<Key, Entry>,
|
||||
unsaved: Vec<(Key, Entry)>,
|
||||
/// Faces that could not be embedded this session (so they are not retried on every call).
|
||||
skipped: std::collections::HashSet<Key>,
|
||||
/// The cache file on disk belongs to this model, so new records can be appended to it.
|
||||
file_matches: bool,
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn get(&self, photo: u64, rect: &Rect) -> Option<&Arc<[f32]>> {
|
||||
self.entries.get(&(photo, region_key(rect))).map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// The box to cut this face's picture from, once the scan has looked at it.
|
||||
pub fn view(&self, photo: u64, rect: &Rect) -> Option<Rect> {
|
||||
self.entries.get(&(photo, region_key(rect))).map(|e| rect_of(e.1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, photo: u64, rect: &Rect) -> bool {
|
||||
self.entries.contains_key(&(photo, region_key(rect)))
|
||||
}
|
||||
|
||||
/// Whether this face still has to be embedded.
|
||||
pub fn needs(&self, photo: u64, rect: &Rect) -> bool {
|
||||
let key = (photo, region_key(rect));
|
||||
!self.entries.contains_key(&key) && !self.skipped.contains(&key)
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, photo: u64, rect: &Rect) {
|
||||
self.skipped.insert((photo, region_key(rect)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn insert(&mut self, photo: u64, rect: &Rect, embedding: Vec<f32>) {
|
||||
self.insert_with_view(photo, rect, embedding, rect);
|
||||
}
|
||||
|
||||
/// Remember a face's embedding and the box to show it by.
|
||||
pub fn insert_with_view(&mut self, photo: u64, rect: &Rect, embedding: Vec<f32>, view: &Rect) {
|
||||
let entry: Entry = (embedding.into(), region_key(view));
|
||||
let key = (photo, region_key(rect));
|
||||
self.entries.insert(key, entry.clone());
|
||||
self.unsaved.push((key, entry));
|
||||
}
|
||||
|
||||
/// Whether there are embeddings not yet written to the cache file.
|
||||
pub fn has_unsaved(&self) -> bool {
|
||||
!self.unsaved.is_empty()
|
||||
}
|
||||
|
||||
/// Start over for `tag` (`dim` numbers per face), loading what the cache file at `path` holds for it. What the
|
||||
/// previous model's index had not written yet is written first, so switching models loses nothing.
|
||||
pub fn reset(&mut self, tag: &str, dim: usize, path: Option<PathBuf>) {
|
||||
let _ = self.save();
|
||||
self.tag = tag.to_string();
|
||||
self.dim = dim;
|
||||
self.path = path;
|
||||
self.entries.clear();
|
||||
self.unsaved.clear();
|
||||
self.skipped.clear();
|
||||
self.file_matches = false;
|
||||
if let Some(p) = self.path.clone() {
|
||||
self.load(&p);
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, path: &Path) {
|
||||
let Ok(meta) = std::fs::metadata(path) else { return };
|
||||
if meta.len() > MAX_FILE {
|
||||
return;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else { return };
|
||||
let Some((tag_len, rest)) = bytes
|
||||
.strip_prefix(MAGIC)
|
||||
.and_then(|r| r.split_first())
|
||||
.filter(|(v, _)| **v == VERSION)
|
||||
.and_then(|(_, r)| r.split_at_checked(2))
|
||||
.map(|(l, r)| (usize::from(u16::from_le_bytes([l[0], l[1]])), r))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((tag, rest)) = rest.split_at_checked(tag_len) else { return };
|
||||
let Some((dim, mut rest)) = rest.split_at_checked(4).map(|(d, r)| (u32::from_le_bytes([d[0], d[1], d[2], d[3]]) as usize, r)) else { return };
|
||||
if tag != self.tag.as_bytes() || dim != self.dim || dim == 0 {
|
||||
return;
|
||||
}
|
||||
self.file_matches = true;
|
||||
let record = 8 + 16 + 16 + dim * 4;
|
||||
while let Some((rec, tail)) = rest.split_at_checked(record) {
|
||||
rest = tail;
|
||||
let photo = u64::from_le_bytes([rec[0], rec[1], rec[2], rec[3], rec[4], rec[5], rec[6], rec[7]]);
|
||||
let int = |at: usize| i32::from_le_bytes([rec[at], rec[at + 1], rec[at + 2], rec[at + 3]]);
|
||||
let key = [int(8), int(12), int(16), int(20)];
|
||||
let view = [int(24), int(28), int(32), int(36)];
|
||||
let emb: Vec<f32> = rec[40..].as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
|
||||
if emb.iter().all(|v| v.is_finite()) {
|
||||
self.entries.insert((photo, key), (emb.into(), view));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn header(&self) -> Vec<u8> {
|
||||
let tag = self.tag.as_bytes();
|
||||
let mut h = Vec::with_capacity(11 + tag.len());
|
||||
h.extend_from_slice(MAGIC);
|
||||
h.push(VERSION);
|
||||
h.extend_from_slice(&u16::try_from(tag.len()).unwrap_or(0).to_le_bytes());
|
||||
h.extend_from_slice(tag);
|
||||
h.extend_from_slice(&(self.dim as u32).to_le_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
fn record(&self, key: &Key, entry: &Entry) -> Vec<u8> {
|
||||
let mut r = Vec::with_capacity(40 + entry.0.len() * 4);
|
||||
r.extend_from_slice(&key.0.to_le_bytes());
|
||||
key.1.iter().chain(entry.1.iter()).for_each(|k| r.extend_from_slice(&k.to_le_bytes()));
|
||||
entry.0.iter().for_each(|v| r.extend_from_slice(&v.to_le_bytes()));
|
||||
r
|
||||
}
|
||||
|
||||
/// Write what was added since the last save: appended when the file already belongs to this model, else the
|
||||
/// whole index is written fresh (atomically). A failure is returned, the records stay pending.
|
||||
pub fn save(&mut self) -> std::io::Result<()> {
|
||||
let Some(path) = self.path.clone() else {
|
||||
self.unsaved.clear();
|
||||
return Ok(());
|
||||
};
|
||||
if self.unsaved.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let mut buf = Vec::new();
|
||||
for (k, e) in &self.unsaved {
|
||||
buf.extend(self.record(k, e));
|
||||
}
|
||||
f.write_all(&buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
for (k, e) in &self.entries {
|
||||
buf.extend(self.record(k, e));
|
||||
}
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
std::fs::rename(&part, &path)?;
|
||||
self.file_matches = true;
|
||||
}
|
||||
self.unsaved.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Index {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.save();
|
||||
}
|
||||
}
|
||||
|
||||
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
|
||||
const SCAN_VERSION: u8 = 1;
|
||||
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
|
||||
pub(crate) const SCAN_TAG: &str = "yunet-2023mar";
|
||||
/// Largest record file we read.
|
||||
const SCAN_MAX_FILE: u64 = 64 << 20;
|
||||
|
||||
/// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft
|
||||
/// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Scanned {
|
||||
set: std::collections::HashSet<u64>,
|
||||
unsaved: Vec<u64>,
|
||||
path: Option<PathBuf>,
|
||||
file_matches: bool,
|
||||
}
|
||||
|
||||
impl Scanned {
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
|
||||
pub fn contains(&self, photo: u64) -> bool {
|
||||
self.set.contains(&photo)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.set.len()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, photo: u64) {
|
||||
if self.set.insert(photo) {
|
||||
self.unsaved.push(photo);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_unsaved(&self) -> bool {
|
||||
!self.unsaved.is_empty()
|
||||
}
|
||||
|
||||
/// Start over with the record at `path` (written first: what was not yet saved is not lost).
|
||||
pub fn reset(&mut self, path: Option<PathBuf>) {
|
||||
let _ = self.save();
|
||||
self.set.clear();
|
||||
self.unsaved.clear();
|
||||
self.file_matches = false;
|
||||
self.path = path;
|
||||
if let Some(p) = self.path.clone() {
|
||||
self.load(&p);
|
||||
}
|
||||
}
|
||||
|
||||
fn header() -> Vec<u8> {
|
||||
let mut h = Vec::new();
|
||||
h.extend_from_slice(SCAN_MAGIC);
|
||||
h.push(SCAN_VERSION);
|
||||
h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(SCAN_TAG.as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
fn load(&mut self, path: &Path) {
|
||||
let Ok(meta) = std::fs::metadata(path) else { return };
|
||||
if meta.len() > SCAN_MAX_FILE {
|
||||
return;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else { return };
|
||||
let head = Self::header();
|
||||
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
|
||||
self.file_matches = true;
|
||||
// a half-written last id (a crash while appending) is ignored
|
||||
self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
}
|
||||
|
||||
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
|
||||
/// is written fresh (atomically). A failure is returned and the ids stay pending.
|
||||
pub fn save(&mut self) -> std::io::Result<()> {
|
||||
let Some(path) = self.path.clone() else {
|
||||
self.unsaved.clear();
|
||||
return Ok(());
|
||||
};
|
||||
if self.unsaved.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
|
||||
f.write_all(&buf)?;
|
||||
} else {
|
||||
let mut buf = Self::header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
std::fs::rename(&part, &path)?;
|
||||
self.file_matches = true;
|
||||
}
|
||||
self.unsaved.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scanned {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.save();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: f64) -> Rect {
|
||||
Rect { x0: x, y0: 0.1, x1: x + 0.2, y1: 0.4 }
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-faceindex-{name}-{}.bin", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeddings_survive_a_save_and_load_and_append() {
|
||||
let path = temp("roundtrip");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut a = Index::default();
|
||||
a.reset("m@abc", 3, Some(path.clone()));
|
||||
a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
|
||||
a.insert(8, &rect(0.3), vec![0.0, 1.0, 0.25]);
|
||||
a.save().unwrap();
|
||||
a.insert(9, &rect(0.5), vec![0.5, 0.5, 0.5]);
|
||||
a.save().unwrap(); // appended
|
||||
let mut b = Index::default();
|
||||
b.reset("m@abc", 3, Some(path.clone()));
|
||||
assert_eq!(b.len(), 3);
|
||||
assert_eq!(b.get(8, &rect(0.3)).map(|e| e.to_vec()), Some(vec![0.0, 1.0, 0.25]));
|
||||
assert!(b.contains(9, &rect(0.5)) && !b.contains(9, &rect(0.6)) && !b.contains(1, &rect(0.5)));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_model_a_damaged_file_or_a_torn_tail_are_handled() {
|
||||
let path = temp("other");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut a = Index::default();
|
||||
a.reset("m@abc", 3, Some(path.clone()));
|
||||
a.insert(1, &rect(0.1), vec![1.0, 0.0, 0.0]);
|
||||
a.insert(2, &rect(0.2), vec![0.0, 1.0, 0.0]);
|
||||
a.save().unwrap();
|
||||
// another model: nothing is loaded, and saving replaces the file for the new model
|
||||
let mut other = Index::default();
|
||||
other.reset("n@def", 4, Some(path.clone()));
|
||||
assert_eq!(other.len(), 0);
|
||||
other.insert(5, &rect(0.1), vec![1.0, 0.0, 0.0, 0.0]);
|
||||
other.save().unwrap();
|
||||
let mut again = Index::default();
|
||||
again.reset("n@def", 4, Some(path.clone()));
|
||||
assert_eq!(again.len(), 1);
|
||||
let mut old = Index::default();
|
||||
old.reset("m@abc", 3, Some(path.clone()));
|
||||
assert_eq!(old.len(), 0, "the old model's records are gone");
|
||||
// a torn last record loses only that record
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
std::fs::write(&path, &bytes[..bytes.len() - 5]).unwrap();
|
||||
let mut torn = Index::default();
|
||||
torn.reset("n@def", 4, Some(path.clone()));
|
||||
assert_eq!(torn.len(), 0);
|
||||
// garbage of every kind is ignored
|
||||
for junk in [Vec::new(), b"LCFE".to_vec(), b"LCFE\x01\xff\xff".to_vec(), vec![0xff; 200], b"LCFE\x02\x00\x00".to_vec()] {
|
||||
std::fs::write(&path, &junk).unwrap();
|
||||
let mut j = Index::default();
|
||||
j.reset("n@def", 4, Some(path.clone()));
|
||||
assert_eq!(j.len(), 0);
|
||||
}
|
||||
// and a missing file is fine
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut m = Index::default();
|
||||
m.reset("n@def", 4, Some(path.clone()));
|
||||
assert_eq!(m.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_boxes_and_vectors_do_not_break_the_keys() {
|
||||
assert_eq!(region_key(&Rect { x0: f64::NAN, y0: f64::INFINITY, x1: 1e300, y1: -1e300 }), [0, 0, 10_000_000, -10_000_000]);
|
||||
let path = temp("nan");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut a = Index::default();
|
||||
a.reset("m@abc", 2, Some(path.clone()));
|
||||
a.insert(1, &rect(0.1), vec![f32::NAN, 1.0]);
|
||||
a.save().unwrap();
|
||||
let mut b = Index::default();
|
||||
b.reset("m@abc", 2, Some(path.clone()));
|
||||
assert_eq!(b.len(), 0, "a non-finite vector is not loaded back");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FacesState {
|
||||
/// `<model id>@<sha256>` and the loaded model.
|
||||
pub embedder: Option<(String, Arc<lightcraft_faces::runtime::Embedder>)>,
|
||||
pub index: Index,
|
||||
/// When the models folder was last looked at (the per-frame pump trusts a look for a second).
|
||||
pub checked: Option<std::time::Instant>,
|
||||
/// Whether recognition was switched on when last looked up, and when.
|
||||
pub enabled_seen: Option<(std::time::Instant, bool)>,
|
||||
pub worker: Option<crate::faces_worker::Worker>,
|
||||
/// After a failure to load the chosen model, the background pump leaves it alone until then.
|
||||
pub retry_at: Option<std::time::Instant>,
|
||||
/// Photos the worker is busy with.
|
||||
pub in_flight: std::collections::HashSet<lightcraft_catalog::PhotoId>,
|
||||
/// Photos still to hand to the worker, and the catalog revision this list was made at.
|
||||
pub queue: Vec<lightcraft_catalog::PhotoId>,
|
||||
pub queue_stamp: Option<u64>,
|
||||
/// When the index was last written to its cache file by the background pump.
|
||||
pub saved_at: Option<std::time::Instant>,
|
||||
/// Bumped when another library is opened: what a worker was making for the old one is dropped.
|
||||
pub epoch: u64,
|
||||
/// Photos already searched for faces.
|
||||
pub scanned: Scanned,
|
||||
/// Photos that could not be searched this session (unreadable files): left alone until the next launch.
|
||||
pub scan_failed: std::collections::HashSet<u64>,
|
||||
}
|
||||
|
||||
impl FacesState {
|
||||
/// Another library is being opened: what was learned about the old one is saved, and nothing of it is kept (photo
|
||||
/// ids are only unique within a library). The loaded model stays; the index is reloaded for the new library.
|
||||
pub fn library_changed(&mut self) {
|
||||
self.index.reset("", 0, None);
|
||||
self.scanned.reset(None);
|
||||
self.scan_failed.clear();
|
||||
self.embedder = None;
|
||||
self.checked = None;
|
||||
self.queue.clear();
|
||||
self.queue_stamp = None;
|
||||
self.in_flight.clear();
|
||||
self.retry_at = None;
|
||||
self.saved_at = None;
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! The heavy part of scanning faces, run off the session thread, on a few threads at once.
|
||||
//!
|
||||
//! For one photo: render it (upright, uncropped, default settings), find its faces with the YuNet detector (when installed)
|
||||
//! so each region can be aligned by landmarks, cut each region out and run the recognition model. A photo that has
|
||||
//! no face regions at all and has not been searched yet also gets its detections back, with their embeddings, so the
|
||||
//! scan decodes each photo once. The session thread only prepares the job (which needs the catalog) and later ingests
|
||||
//! the result, so the window never waits for a model.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::{Arc, Condvar, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_faces::align::{Rgb, align_to_template, crop_box};
|
||||
use lightcraft_faces::runtime::Embedder;
|
||||
use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_geom::Rect;
|
||||
|
||||
use crate::media::{PreviewLoader, RenderJob};
|
||||
|
||||
/// Long edge of the picture faces are cut from.
|
||||
pub(crate) const EDGE: usize = 2048;
|
||||
/// A box from an XMP sidecar is cut with this much room around it (there are no landmarks to align by).
|
||||
pub(crate) const BOX_PAD: f32 = 1.5;
|
||||
/// How much a detected face must overlap a region to lend it its landmarks.
|
||||
pub(crate) const MATCH_IOU: f32 = 0.3;
|
||||
/// A detection must be this sure to become a face region (the bar `faces.detect` uses).
|
||||
pub(crate) const REGION_SCORE: f32 = 0.6;
|
||||
/// A raw's embedded preview is used for the scan only when its long edge is at least this (a thumbnail is too small to
|
||||
/// find faces in; the raw itself is decoded then).
|
||||
const MIN_PREVIEW_EDGE: usize = 1000;
|
||||
|
||||
/// Most photos ever worked on at once.
|
||||
const MAX_WORKERS: usize = 24;
|
||||
/// What one photo in progress holds: the file, its decoded picture and the copies the detector and recogniser read.
|
||||
/// Measured: peak memory grew by about 180 MB for each extra worker.
|
||||
const WORKER_BYTES: usize = 180 << 20;
|
||||
/// Photos queued behind the ones running, so a worker that finishes never waits for the next frame to be fed.
|
||||
pub(crate) const PREFETCH: usize = 2;
|
||||
|
||||
/// How hard the background scan may work right now, as the app judges from what the user is doing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Pace {
|
||||
/// Start nothing new: the user is dragging, typing or scrolling.
|
||||
Pause = 0,
|
||||
/// One photo at a time: the user is around, or the window is minimized.
|
||||
Light = 1,
|
||||
/// Half the machine: the user is idle.
|
||||
Normal = 2,
|
||||
/// Most of the machine: the user is watching the progress.
|
||||
Full = 3,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
fn from_u8(n: u8) -> Pace {
|
||||
match n {
|
||||
0 => Pace::Pause,
|
||||
1 => Pace::Light,
|
||||
3 => Pace::Full,
|
||||
_ => Pace::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
/// `pause`, `light`, `normal` or `full`; anything else (or nothing) is `normal`.
|
||||
pub fn parse(name: Option<&str>) -> Pace {
|
||||
match name {
|
||||
Some("pause") => Pace::Pause,
|
||||
Some("light") => Pace::Light,
|
||||
Some("full") => Pace::Full,
|
||||
_ => Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many photos are worked on at once at `pace` on a machine with `cores` threads (as the system reports them, which
|
||||
/// respects container limits and affinity). `cap` is a limit the user set (`LIGHTCRAFT_FACE_THREADS`). Memory is not
|
||||
/// decided here: decodes wait at the process-wide memory gate ([`crate::memory::work_gate`]), sized from the RAM.
|
||||
pub(crate) fn workers_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize {
|
||||
let n = match pace {
|
||||
Pace::Pause => return 0,
|
||||
Pace::Light => 1,
|
||||
Pace::Normal => cores / 2,
|
||||
Pace::Full => cores * 4 / 5,
|
||||
}
|
||||
.clamp(1, MAX_WORKERS);
|
||||
cap.map_or(n, |c| n.min(c.clamp(1, MAX_WORKERS)))
|
||||
}
|
||||
|
||||
fn cores() -> usize {
|
||||
std::thread::available_parallelism().map_or(1, |n| n.get())
|
||||
}
|
||||
|
||||
fn cap_from_env() -> Option<usize> {
|
||||
std::env::var("LIGHTCRAFT_FACE_THREADS").ok().and_then(|v| v.trim().parse::<usize>().ok())
|
||||
}
|
||||
|
||||
/// The most photos the memory budget can hold in progress at once: half of it (the rest is the window's, the caches',
|
||||
/// the renders'), a worker at a time. Raising the budget (`LIGHTCRAFT_MEMORY_MB`) raises this.
|
||||
fn memory_workers() -> usize {
|
||||
(crate::memory::budget() / 2 / WORKER_BYTES).max(1)
|
||||
}
|
||||
|
||||
/// Photos to work on at once at `pace` on this machine: by the processor's threads and the pace, and never more than
|
||||
/// the memory allows. A limit the user sets (`LIGHTCRAFT_FACE_THREADS`) replaces the memory one.
|
||||
pub(crate) fn target_workers(pace: Pace) -> usize {
|
||||
workers_for(cores(), pace, Some(cap_from_env().unwrap_or_else(memory_workers)))
|
||||
}
|
||||
|
||||
/// Threads the worker pool starts with: enough for the fullest pace (the idle ones cost nothing).
|
||||
pub(crate) fn max_workers() -> usize {
|
||||
target_workers(Pace::Full)
|
||||
}
|
||||
|
||||
/// Threads for the scan's own parallel work (decoding a picture, developing it) at `pace` on a machine with `cores` threads:
|
||||
/// two when light, half the machine when normal, four fifths when watched. A photo's parallel parts are most of its cost,
|
||||
/// so this, more than the number of photos at once, is how much of the processor the scan takes. Measured on a 32-thread
|
||||
/// desktop (4 photos at once): 8 threads scanned 2.2 photos a second, 16 threads 5.8, 25 threads 6.4.
|
||||
fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize {
|
||||
let n = match pace {
|
||||
Pace::Pause | Pace::Light => 2,
|
||||
Pace::Normal => cores / 2,
|
||||
Pace::Full => cores * 4 / 5,
|
||||
}
|
||||
.clamp(2, 32);
|
||||
cap.map_or(n, |c| n.min(c.max(1)))
|
||||
}
|
||||
|
||||
/// One photo's faces to embed (and, if `detect`, to find first).
|
||||
pub(crate) struct Prepared {
|
||||
/// `<model id>@<sha256>` the embeddings are for.
|
||||
pub tag: String,
|
||||
/// The session's face epoch when this was prepared: another library has been opened since if it differs.
|
||||
pub epoch: u64,
|
||||
pub id: PhotoId,
|
||||
pub job: RenderJob,
|
||||
/// A raw's embedded preview, tried before the render job (much faster than decoding the raw).
|
||||
pub preview: Option<(String, PreviewLoader)>,
|
||||
pub todo: Vec<Rect>,
|
||||
/// The photo has no face regions and has not been searched: look for faces too.
|
||||
pub detect: bool,
|
||||
/// The YuNet detector, when the user has installed it: faces are aligned by its landmarks, and it finds the faces of
|
||||
/// photos that have none. Without it a scan only embeds the faces the photos already have, cut by their boxes.
|
||||
pub detector: Option<Arc<Detector>>,
|
||||
}
|
||||
|
||||
/// The embeddings made for a [`Prepared`]; `None` for a face that could not be embedded.
|
||||
pub(crate) struct Done {
|
||||
pub tag: String,
|
||||
pub epoch: u64,
|
||||
pub id: PhotoId,
|
||||
/// Each face's region, its embedding (`None` when it could not be made) and the box to show it by.
|
||||
pub results: Vec<(Rect, Option<Vec<f32>>, Rect)>,
|
||||
/// Faces were to be looked for in this photo.
|
||||
pub detect_wanted: bool,
|
||||
/// What the detector found (their embeddings are in `results`); `None` when the photo could not be searched.
|
||||
pub found: Option<Vec<Rect>>,
|
||||
}
|
||||
|
||||
fn overlap(a: &Rect, f: &Face) -> f32 {
|
||||
let (w, h) = ((a.x1.min(f64::from(f.x1)) - a.x0.max(f64::from(f.x0))).max(0.0), (a.y1.min(f64::from(f.y1)) - a.y0.max(f64::from(f.y0))).max(0.0));
|
||||
let inter = w * h;
|
||||
let union = (a.x1 - a.x0) * (a.y1 - a.y0) + f64::from((f.x1 - f.x0) * (f.y1 - f.y0)) - inter;
|
||||
if union > 0.0 { (inter / union) as f32 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A detection as a region box: inside the photo and not a sliver; `None` for anything else (it is only ever data).
|
||||
fn face_rect(f: &Face) -> Option<Rect> {
|
||||
if ![f.x0, f.y0, f.x1, f.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let c = |v: f32| f64::from(v.clamp(0.0, 1.0));
|
||||
let r = Rect { x0: c(f.x0), y0: c(f.y0), x1: c(f.x1), y1: c(f.y1) };
|
||||
(r.x1 - r.x0 > 0.005 && r.y1 - r.y0 > 0.005).then_some(r)
|
||||
}
|
||||
|
||||
/// Do the work for one photo. Never panics: whatever goes wrong, its faces come back as `None`.
|
||||
pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
let Prepared { tag, epoch, id, job, preview, todo, detect, detector } = p;
|
||||
let fallback = |todo: Vec<Rect>| todo.into_iter().map(|r| (r, None, r)).collect::<Vec<_>>();
|
||||
let rects = todo.clone();
|
||||
let run = catch_unwind(AssertUnwindSafe(|| {
|
||||
let from_preview = preview.and_then(|(path, load)| load(&path, EDGE)).filter(|img| img.width.max(img.height) >= MIN_PREVIEW_EDGE);
|
||||
let image = match from_preview {
|
||||
Some(img) => img,
|
||||
None => match job.run().rendered {
|
||||
Ok(rendered) => rendered.image,
|
||||
Err(_) => return (fallback(todo), None),
|
||||
},
|
||||
};
|
||||
let (w, h) = (image.width, image.height);
|
||||
let rgb: Vec<u8> = image.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
|
||||
// the detector's landmarks, where it finds the same face: faces are aligned by them, not cut by a box
|
||||
let detected = detector.as_deref().and_then(|d| d.detect(&rgb, w, h, &Options { score: 0.5, ..Options::default() }).ok());
|
||||
let found = detected.clone().unwrap_or_default();
|
||||
let img = Rgb { data: &rgb, width: w, height: h };
|
||||
let (iw, ih) = embedder.input_size();
|
||||
let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32));
|
||||
let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo
|
||||
.into_iter()
|
||||
.map(|rect| {
|
||||
let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let crop = match best {
|
||||
Some((_, f)) => align_to_template(&img, &scaled(f), iw, ih),
|
||||
None => crop_box(
|
||||
&img,
|
||||
rect.x0 as f32 * w as f32,
|
||||
rect.y0 as f32 * h as f32,
|
||||
rect.x1 as f32 * w as f32,
|
||||
rect.y1 as f32 * h as f32,
|
||||
BOX_PAD,
|
||||
iw,
|
||||
ih,
|
||||
),
|
||||
};
|
||||
let v = crop.and_then(|c| embedder.embed(&c).ok());
|
||||
// shown by the detector's box where it found this face: every face then has the same tightness
|
||||
let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect);
|
||||
(rect, v, view)
|
||||
})
|
||||
.collect();
|
||||
// a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass
|
||||
let new_faces = if detect {
|
||||
detected.map(|faces| {
|
||||
let mut rects = Vec::new();
|
||||
for f in faces.iter().filter(|f| f.score >= REGION_SCORE) {
|
||||
let Some(rect) = face_rect(f) else { continue };
|
||||
let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok());
|
||||
results.push((rect, v, rect));
|
||||
rects.push(rect);
|
||||
}
|
||||
rects
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(results, new_faces)
|
||||
}));
|
||||
let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None));
|
||||
Done { tag, epoch, id, results, detect_wanted: detect, found }
|
||||
}
|
||||
|
||||
type Job = (Arc<Embedder>, Prepared);
|
||||
|
||||
/// How many photos may be worked on at once, changed at any moment by the pace: a thread that has a job waits here until
|
||||
/// fewer than that are running. A pace of zero holds every job where it is, with nothing decoded.
|
||||
struct Limit {
|
||||
allowed: AtomicUsize,
|
||||
/// The pace the limit was last set for (a [`Pace`] as a number): the pool a photo's parallel work uses.
|
||||
pace: AtomicU8,
|
||||
running: Mutex<usize>,
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
impl Limit {
|
||||
fn new() -> Limit {
|
||||
Limit { allowed: AtomicUsize::new(0), pace: AtomicU8::new(Pace::Normal as u8), running: Mutex::new(0), wake: Condvar::new() }
|
||||
}
|
||||
|
||||
fn set(&self, n: usize, pace: Pace) {
|
||||
self.pace.store(pace as u8, Ordering::Relaxed);
|
||||
self.allowed.store(n, Ordering::Relaxed);
|
||||
self.wake.notify_all();
|
||||
}
|
||||
|
||||
fn pace(&self) -> Pace {
|
||||
Pace::from_u8(self.pace.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Wait for a place; the limit is looked at again whenever it changes and at least every 100 ms.
|
||||
fn enter(&self) {
|
||||
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while *running >= self.allowed.load(Ordering::Relaxed) {
|
||||
running = self.wake.wait_timeout(running, Duration::from_millis(100)).unwrap_or_else(PoisonError::into_inner).0;
|
||||
}
|
||||
*running += 1;
|
||||
}
|
||||
|
||||
fn leave(&self) {
|
||||
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
*running = running.saturating_sub(1);
|
||||
drop(running);
|
||||
self.wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// The threads a photo's parallel work (decoding, developing a picture) runs on at `pace`: one pool for each of three sizes
|
||||
/// ([`pool_threads_for`]), made when first needed, whose idle threads cost nothing. rayon's global pool serves the
|
||||
/// interactive work (the loupe, exports) and has no priorities, so scan work queued there would make a slider drag wait
|
||||
/// behind it; here a light scan can use only two threads however large the machine is.
|
||||
fn scan_pool(pace: Pace) -> Option<&'static rayon::ThreadPool> {
|
||||
static LIGHT: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
|
||||
static NORMAL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
|
||||
static FULL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
|
||||
let (cell, pace) = match pace {
|
||||
Pace::Pause | Pace::Light => (&LIGHT, Pace::Light),
|
||||
Pace::Normal => (&NORMAL, Pace::Normal),
|
||||
Pace::Full => (&FULL, Pace::Full),
|
||||
};
|
||||
cell.get_or_init(|| {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(pool_threads_for(cores(), pace, cap_from_env()))
|
||||
.thread_name(move |i| format!("lightcraft-faces-{}-{i}", pace as u8))
|
||||
.build()
|
||||
.ok()
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Run one photo on the pool for the pace, so its parallel parts stay there.
|
||||
///
|
||||
/// It must NOT wait at the memory gate ([`crate::memory::work_gate`], which background decodes do): a job running on a
|
||||
/// pool thread that waits inside a nested rayon call can pick up another job to run on top of itself, and if that one waits
|
||||
/// for memory the first holds, neither can finish (seen as a few photos that never come back). The memory a scan may use is
|
||||
/// instead limited by how many photos run at once ([`memory_workers`]); its decodes count as interactive ones at the gate,
|
||||
/// which never wait.
|
||||
fn process_in_background(prepared: Prepared, embedder: &Embedder, pace: Pace) -> Done {
|
||||
let work = || process(prepared, embedder);
|
||||
match scan_pool(pace) {
|
||||
Some(pool) => pool.install(work),
|
||||
None => work(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Threads that run [`process`] for the jobs they are given, each on one photo at a time.
|
||||
pub(crate) struct Worker {
|
||||
jobs: Sender<Job>,
|
||||
done: Receiver<Done>,
|
||||
limit: Arc<Limit>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn start(threads: usize) -> Option<Worker> {
|
||||
let (jobs, job_rx) = channel::<Job>();
|
||||
let (done_tx, done) = channel::<Done>();
|
||||
let job_rx = Arc::new(Mutex::new(job_rx));
|
||||
let limit = Arc::new(Limit::new());
|
||||
let mut started = 0;
|
||||
for i in 0..threads.clamp(1, MAX_WORKERS) {
|
||||
let (rx, tx, limit) = (job_rx.clone(), done_tx.clone(), limit.clone());
|
||||
let spawned = std::thread::Builder::new().name(format!("lightcraft-faces-{i}")).spawn(move || {
|
||||
loop {
|
||||
// the lock is held only while waiting for a job, never while working on one; the threads end when
|
||||
// the session (the only sender) is dropped
|
||||
let next = rx.lock().unwrap_or_else(PoisonError::into_inner).recv();
|
||||
let Ok((embedder, prepared)) = next else { break };
|
||||
limit.enter();
|
||||
let done = process_in_background(prepared, &embedder, limit.pace());
|
||||
limit.leave();
|
||||
if tx.send(done).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
started += 1;
|
||||
}
|
||||
}
|
||||
(started > 0).then_some(Worker { jobs, done, limit })
|
||||
}
|
||||
|
||||
/// How many photos may be worked on at once from now on (what is running is not interrupted).
|
||||
pub fn allow(&self, n: usize, pace: Pace) {
|
||||
self.limit.set(n, pace);
|
||||
}
|
||||
|
||||
pub fn submit(&self, embedder: Arc<Embedder>, prepared: Prepared) -> bool {
|
||||
self.jobs.send((embedder, prepared)).is_ok()
|
||||
}
|
||||
|
||||
/// Everything finished since the last call.
|
||||
pub fn finished(&self) -> Vec<Done> {
|
||||
self.done.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_pace_sets_how_much_of_the_machine_the_scan_may_use() {
|
||||
use Pace::*;
|
||||
// a 32-thread desktop: nothing while the user works, one photo around them, half when idle, most when watched
|
||||
assert_eq!([Pause, Light, Normal, Full].map(|p| workers_for(32, p, None)), [0, 1, 16, 24]);
|
||||
assert_eq!([Pause, Light, Normal, Full].map(|p| workers_for(8, p, None)), [0, 1, 4, 6]);
|
||||
assert_eq!([Pause, Light, Normal, Full].map(|p| workers_for(4, p, None)), [0, 1, 2, 3]);
|
||||
// small machines still get one worker (except while paused)
|
||||
assert_eq!([Pause, Light, Normal, Full].map(|p| workers_for(1, p, None)), [0, 1, 1, 1]);
|
||||
assert_eq!(workers_for(0, Full, None), 1);
|
||||
assert_eq!(workers_for(10_000, Full, None), MAX_WORKERS);
|
||||
// a limit set by the user caps every pace, and is itself kept sane
|
||||
assert_eq!([Light, Normal, Full].map(|p| workers_for(32, p, Some(3))), [1, 3, 3]);
|
||||
assert_eq!((workers_for(32, Full, Some(0)), workers_for(32, Pause, Some(5))), (1, 0));
|
||||
assert_eq!(workers_for(32, Full, Some(usize::MAX)), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limit_holds_threads_to_the_pace_and_can_change_at_any_moment() {
|
||||
let limit = Arc::new(Limit::new());
|
||||
let (now, peak) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let threads: Vec<_> = (0..6)
|
||||
.map(|_| {
|
||||
let (limit, now, peak, started) = (limit.clone(), now.clone(), peak.clone(), started.clone());
|
||||
std::thread::spawn(move || {
|
||||
limit.enter();
|
||||
started.fetch_add(1, Ordering::SeqCst);
|
||||
let n = now.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
peak.fetch_max(n, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
now.fetch_sub(1, Ordering::SeqCst);
|
||||
limit.leave();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// paused: nothing starts
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
assert_eq!(started.load(Ordering::SeqCst), 0);
|
||||
// two at a time, never more
|
||||
limit.set(2, Pace::Normal);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert!(started.load(Ordering::SeqCst) >= 2);
|
||||
// raised, the rest go; every thread finishes
|
||||
limit.set(6, Pace::Full);
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(started.load(Ordering::SeqCst), 6);
|
||||
assert!(peak.load(Ordering::SeqCst) >= 2, "it did run in parallel");
|
||||
assert_eq!(*limit.running.lock().unwrap(), 0, "every place was given back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_limits_the_workers_and_a_users_limit_replaces_it() {
|
||||
// the default budget (a quarter of the RAM, at most 1.5 GiB) holds a few photos in progress, not dozens
|
||||
assert!((1..=8).contains(&memory_workers()), "{}", memory_workers());
|
||||
assert!(target_workers(Pace::Full) >= 1);
|
||||
assert_eq!(target_workers(Pace::Pause), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_names_are_forgiving() {
|
||||
assert_eq!(["pause", "light", "normal", "full"].map(|n| Pace::parse(Some(n))), [Pace::Pause, Pace::Light, Pace::Normal, Pace::Full]);
|
||||
assert_eq!(
|
||||
(Pace::parse(None), Pace::parse(Some("")), Pace::parse(Some("FULL")), Pace::parse(Some("turbo"))),
|
||||
(Pace::Normal, Pace::Normal, Pace::Normal, Pace::Normal)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scans_parallel_work_gets_a_pool_the_size_of_the_pace() {
|
||||
use Pace::*;
|
||||
assert_eq!([Pause, Light, Normal, Full].map(|p| pool_threads_for(32, p, None)), [2, 2, 16, 25]);
|
||||
assert_eq!([Light, Normal, Full].map(|p| pool_threads_for(8, p, None)), [2, 4, 6]);
|
||||
assert_eq!([Light, Normal, Full].map(|p| pool_threads_for(2, p, None)), [2, 2, 2]);
|
||||
assert_eq!(pool_threads_for(10_000, Full, None), 32);
|
||||
assert_eq!([Light, Normal, Full].map(|p| pool_threads_for(32, p, Some(3))), [2, 3, 3]);
|
||||
// the pools exist and are the sizes asked for
|
||||
for pace in [Light, Normal, Full] {
|
||||
let pool = scan_pool(pace).expect("a pool can be built");
|
||||
assert_eq!(pool.install(rayon::current_num_threads), pool_threads_for(cores(), pace, cap_from_env()));
|
||||
}
|
||||
// a paused scan has the light pool; the pace is remembered as a number and read back
|
||||
assert_eq!(scan_pool(Pause).map(|p| p as *const _), scan_pool(Light).map(|p| p as *const _));
|
||||
assert_eq!([0, 1, 2, 3, 9].map(Pace::from_u8), [Pause, Light, Normal, Full, Normal]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detections_become_boxes_only_when_they_make_sense() {
|
||||
let f = |x0, y0, x1, y1| Face { x0, y0, x1, y1, score: 0.9, landmarks: [(0.0, 0.0); 5] };
|
||||
assert!(face_rect(&f(0.2, 0.2, 0.4, 0.5)).is_some());
|
||||
// a box that spills over the edge is brought back inside
|
||||
let r = face_rect(&f(-0.1, 0.1, 0.3, 1.2)).unwrap();
|
||||
assert_eq!((r.x0, r.y1), (0.0, 1.0));
|
||||
// slivers, inverted boxes and non-numbers are dropped, never turned into regions
|
||||
for bad in [f(0.2, 0.2, 0.2, 0.5), f(0.4, 0.2, 0.2, 0.5), f(f32::NAN, 0.2, 0.4, 0.5), f(0.2, 0.2, f32::INFINITY, 0.5), f(1.5, 0.2, 2.0, 0.5)]
|
||||
{
|
||||
assert!(face_rect(&bad).is_none(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+504
-56
@@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_raster::resample::{Filter, fit};
|
||||
|
||||
use crate::media::{FileLoader, FileProbe, PreviewLoader, ProbeInfo};
|
||||
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo};
|
||||
|
||||
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
|
||||
@@ -92,16 +92,18 @@ fn ext_upper(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their
|
||||
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed:
|
||||
/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too
|
||||
/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside
|
||||
/// compressed data that is all there: such a file imports and shows as unreadable when rendered).
|
||||
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
|
||||
let m = lightcraft_meta::extract(bytes);
|
||||
let (meta, captured) = meta_of(&m);
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let raw = match lightcraft_raw::probe_info(bytes) {
|
||||
let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) {
|
||||
Ok(r) => r,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
Err(Ok(why)) => {
|
||||
// a raw variant we can't decode yet: describe it from its embedded preview
|
||||
let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?;
|
||||
return Ok(ProbeInfo {
|
||||
@@ -119,7 +121,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||||
if w <= 1 || h <= 1 {
|
||||
@@ -152,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
if !fmt.can_decode() {
|
||||
return Err(format!("{fmt:?} files are not supported yet"));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
|
||||
let o = Orientation::from_exif(d.orientation);
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||||
let o = Orientation::from_exif(header.orientation);
|
||||
let (mut w, mut h) = (header.width, header.height);
|
||||
if o.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
@@ -179,6 +182,21 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error
|
||||
/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX
|
||||
/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed.
|
||||
/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF
|
||||
/// structure): its preview may still be intact. Only a file that is not a raw reports an error.
|
||||
fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, String> {
|
||||
use lightcraft_raw::RawError;
|
||||
match e {
|
||||
RawError::Unsupported(why) => Ok(why),
|
||||
e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")),
|
||||
RawError::NotRaw => Err(RawError::NotRaw.to_string()),
|
||||
e => Ok(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||||
|
||||
@@ -202,24 +220,58 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usi
|
||||
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
|
||||
/// loops themselves when the machine is busy).
|
||||
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge))
|
||||
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
|
||||
}
|
||||
|
||||
/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded
|
||||
/// (less memory held while it is developed).
|
||||
pub fn load_vec(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge))
|
||||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
|
||||
}
|
||||
|
||||
fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain
|
||||
/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all
|
||||
/// there is) and the source facts. Both pictures are the same size.
|
||||
pub fn load_vec_pair(bytes: Vec<u8>, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec)))
|
||||
}
|
||||
|
||||
/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default
|
||||
/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned. `None` when the product
|
||||
/// is missing, damaged, made from something else or not the size of this raw's active area.
|
||||
fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option<Rgb32f> {
|
||||
use lightcraft_denoise::product::{self, Window};
|
||||
let a = raw.active_area;
|
||||
let header = product::read_header_at(&spec.product).ok()?;
|
||||
if header.key != spec.key || (header.width, header.height) != (a.width, a.height) {
|
||||
return None;
|
||||
}
|
||||
if raw.opcodes.list3.is_empty() {
|
||||
let c = raw.develop_crop(a.width, a.height);
|
||||
let window = Window { x: c.x, y: c.y, width: c.width, height: c.height };
|
||||
product::read_window(&spec.product, &spec.key, Some(window), factor, Some(HIGHLIGHT_CLIP)).ok()
|
||||
} else {
|
||||
// (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1)
|
||||
let whole = product::read(&spec.product, &spec.key, 1).ok()?;
|
||||
Some(raw.finish_demosaiced(whole))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bytes_now(
|
||||
bytes: std::borrow::Cow<'_, [u8]>,
|
||||
max_edge: usize,
|
||||
denoise: Option<&DenoiseSpec>,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||||
if lightcraft_raw::probe(&bytes).is_some() {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes) {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
Ok(r) => r,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
Err(Ok(why)) => {
|
||||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||||
return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
return load_embedded_preview(&bytes, max_edge)
|
||||
.map(|(img, info)| (img, None, info))
|
||||
.ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
@@ -227,53 +279,63 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the preview is fitted
|
||||
let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw));
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = web_time::Instant::now();
|
||||
let binned = match bin_factor(&raw, max_edge) {
|
||||
let bin = bin_factor(&raw, max_edge);
|
||||
let binned = match bin {
|
||||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||||
None => None,
|
||||
};
|
||||
let mut img = match binned {
|
||||
let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 };
|
||||
let img = match binned {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
raw.develop(method).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
// the same development from the denoised mosaic, when the photo has been denoised
|
||||
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
let wb = t.wb;
|
||||
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
let wb = t.wb;
|
||||
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
|
||||
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
// after the baseline exposure, as when it was fitted
|
||||
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.into_oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
|
||||
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
|
||||
let finish = |mut img: Rgb32f| {
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.into_oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
(img, stages)
|
||||
};
|
||||
let (img, stages) = finish(img);
|
||||
if lightcraft_pipeline::profiling() {
|
||||
let mut prev = std::time::Duration::ZERO;
|
||||
let parts: Vec<String> = stages
|
||||
@@ -286,17 +348,64 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
.collect();
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let twin = twin.map(|picture| finish(picture).0);
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
|
||||
// White balance re-evaluates the file's own colour model (when it has one and no
|
||||
// file-local look matrix sits on top of it)
|
||||
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
|
||||
return Ok((
|
||||
img,
|
||||
twin,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
|
||||
));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
|
||||
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
|
||||
drop(bytes);
|
||||
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 };
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
|
||||
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
|
||||
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
|
||||
fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::masks::Mattes>> {
|
||||
use lightcraft_pipeline::masks::{MatteKind, Mattes};
|
||||
if raw.format != lightcraft_raw::RawFormat::Dng {
|
||||
return None;
|
||||
}
|
||||
let mut mattes = Mattes::default();
|
||||
for m in lightcraft_raw::semantic_masks(bytes) {
|
||||
let Some(kind) = matte_kind(&m.name) else { continue };
|
||||
let Some(img) = m.developed(raw.active_area, raw.crop) else { continue };
|
||||
if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) {
|
||||
continue;
|
||||
}
|
||||
mattes.push(kind, img.into_oriented(raw.orientation));
|
||||
}
|
||||
(!mattes.is_empty()).then(|| Arc::new(mattes))
|
||||
}
|
||||
|
||||
/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after
|
||||
/// their auxiliary image types, `urn:com:apple:photo:<year>:aux:<type>`. Other names: `None`.
|
||||
fn matte_kind(name: &str) -> Option<lightcraft_pipeline::masks::MatteKind> {
|
||||
use lightcraft_pipeline::masks::MatteKind::*;
|
||||
let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?;
|
||||
Some(match kind {
|
||||
"semanticskymatte" => Sky,
|
||||
"semanticskinmatte" => Skin,
|
||||
"semantichairmatte" => Hair,
|
||||
"semanticteethmatte" => Teeth,
|
||||
"semanticglassesmatte" => Glasses,
|
||||
"portraiteffectsmatte" => Person,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the
|
||||
@@ -318,10 +427,24 @@ 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) {
|
||||
return Some(std::borrow::Cow::Owned(jpeg));
|
||||
}
|
||||
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
|
||||
(undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff))
|
||||
.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 = lightcraft_raw::embedded_preview(bytes)?;
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes) {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
// 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) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
|
||||
@@ -335,7 +458,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 = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
if preview_orientation(bytes, d.orientation).swaps_axes() {
|
||||
@@ -346,7 +469,7 @@ 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, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
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()))
|
||||
@@ -355,7 +478,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
|
||||
/// 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`]).
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
let mut d = decode_raw_preview(bytes, fit_box(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;
|
||||
@@ -364,9 +487,39 @@ pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft
|
||||
Some(d.to_srgb8().oriented(o))
|
||||
}
|
||||
|
||||
/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past
|
||||
/// `u32::MAX` asks for the largest box rather than wrapping to a small one.
|
||||
fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
|
||||
let e = u32::try_from(max_edge).unwrap_or(u32::MAX);
|
||||
lightcraft_codecs::DecodeOptions::fit(e, e)
|
||||
}
|
||||
|
||||
/// Filesystem-backed embedded-preview hook (native).
|
||||
///
|
||||
/// Reads the whole file to find the preview, so it holds the memory gate for twice the file's size (the file and the
|
||||
/// decoded preview): background work (the face scan) waits for room, interactive work is counted and never waits.
|
||||
pub fn fs_preview_loader() -> PreviewLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge))
|
||||
Arc::new(|path: &str, max_edge: usize| {
|
||||
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)) };
|
||||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||||
})
|
||||
}
|
||||
|
||||
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
|
||||
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
|
||||
pub fn fs_pair_loader() -> PairLoader {
|
||||
Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| {
|
||||
let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
|
||||
let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 };
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let r = load_vec_pair(bytes, max_edge, spec);
|
||||
crate::memory::release();
|
||||
r
|
||||
})
|
||||
}
|
||||
|
||||
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
|
||||
@@ -378,7 +531,7 @@ pub fn fs_preview_loader() -> PreviewLoader {
|
||||
/// yields to them.
|
||||
pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
|
||||
let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 };
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
|
||||
@@ -389,7 +542,7 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
r
|
||||
});
|
||||
let probe: FileProbe = Arc::new(|path: &str| {
|
||||
let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let _permit = crate::memory::work_gate().acquire(len);
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
probe_bytes(path, &bytes)
|
||||
@@ -404,6 +557,7 @@ impl crate::Session {
|
||||
self.media.file_loader = Some(l);
|
||||
self.media.file_probe = Some(p);
|
||||
self.media.preview_loader = Some(fs_preview_loader());
|
||||
self.media.denoise.loader = Some(fs_pair_loader());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -413,6 +567,68 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
|
||||
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||||
/// file or one that isn't an image.
|
||||
#[test]
|
||||
fn image_probe_reads_headers_like_the_decode_did() {
|
||||
let (w, h) = (300u32, 200u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
for o in [None, Some(1), Some(6), Some(3), Some(8)] {
|
||||
let exif = o.map(lightcraft_codecs::exif::minimal_exif);
|
||||
let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap();
|
||||
let png = lightcraft_codecs::encode_png(&img, &meta).unwrap();
|
||||
for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] {
|
||||
let p = probe_bytes(name, bytes).unwrap();
|
||||
// what the probe computed from a decode before
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap();
|
||||
let swap = Orientation::from_exif(d.orientation).swaps_axes();
|
||||
let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) };
|
||||
assert_eq!((p.width, p.height), want, "{name} {o:?}");
|
||||
assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}");
|
||||
assert_eq!(p.kind, MediaKind::Image);
|
||||
assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" });
|
||||
assert_eq!(p.file_size, bytes.len() as u64);
|
||||
assert!(p.content_hash.is_some());
|
||||
assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted");
|
||||
}
|
||||
}
|
||||
assert!(probe_bytes("x.jpg", b"not an image").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() {
|
||||
use lightcraft_denoise::product;
|
||||
use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng};
|
||||
let dir = crate::tests_xmp::temp_dir("denoise-color-finish");
|
||||
let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&fixture).unwrap();
|
||||
raw.orientation = Orientation::Rotate90;
|
||||
raw.color.baseline_exposure = 0.5;
|
||||
raw.color.profile.look_table =
|
||||
Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false });
|
||||
let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap();
|
||||
let decoded = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert!(decoded.color.profile.look_table.is_some());
|
||||
// A cache containing the ordinary demosaic must undergo the identical downstream finish.
|
||||
let camera_rgb = decoded.develop(Method::Ahd).unwrap();
|
||||
let key = "synthetic-color-finish";
|
||||
let path = dir.join("same.lcdn");
|
||||
product::write(&path, &camera_rgb, key).unwrap();
|
||||
let spec = DenoiseSpec { product: path, key: key.into(), make: None };
|
||||
let (ordinary, _) = load_bytes(&bytes, 2048).unwrap();
|
||||
let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap();
|
||||
assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path");
|
||||
let cached = cached.unwrap();
|
||||
assert_eq!((plain.width, plain.height), (24, 32));
|
||||
assert_eq!((cached.width, cached.height), (plain.width, plain.height));
|
||||
let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||||
/// mostly cancel, edges remain.
|
||||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
@@ -553,6 +769,43 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage:
|
||||
/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`).
|
||||
fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec<u8> {
|
||||
let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat();
|
||||
let mut craw = vec![0u8; 82];
|
||||
craw[24..26].copy_from_slice(&(w as u16).to_be_bytes());
|
||||
craw[26..28].copy_from_slice(&(h as u16).to_be_bytes());
|
||||
craw.extend(bx(b"CMP1", &[0xff; 4]));
|
||||
let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1];
|
||||
stsd.extend(bx(b"CRAW", &craw));
|
||||
let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat();
|
||||
let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat();
|
||||
let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat();
|
||||
let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))));
|
||||
let preview = cr3_with_preview(w, h);
|
||||
[&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat()
|
||||
}
|
||||
|
||||
/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG:
|
||||
/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review).
|
||||
#[test]
|
||||
fn corrupt_cr3_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_corrupt_raw(48, 32);
|
||||
let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt");
|
||||
assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}");
|
||||
let p = probe_bytes("x.cr3", &b).unwrap();
|
||||
assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3"));
|
||||
assert!(p.preview_only.is_some_and(|why| why.contains("CR3")));
|
||||
let (img, src) = load_bytes(&b, 24).unwrap();
|
||||
assert_eq!((img.width, img.height), (24, 16));
|
||||
assert!(!src.raw);
|
||||
// other formats still report a corrupt file as an error
|
||||
let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
dng.truncate(dng.len() / 2);
|
||||
assert!(load_bytes(&dng, 24).is_err());
|
||||
}
|
||||
|
||||
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
|
||||
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
|
||||
#[test]
|
||||
@@ -569,8 +822,12 @@ mod tests {
|
||||
assert!(info.camera_tone.is_none());
|
||||
// a map that removes all saturation, and a tone curve
|
||||
let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false };
|
||||
raw.color.profile =
|
||||
ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) };
|
||||
raw.color.profile = ProfileLook {
|
||||
hue_sat_map: [Some(grey), None],
|
||||
look_table: None,
|
||||
tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]),
|
||||
gain_table_map: None,
|
||||
};
|
||||
let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
let (after, info) = load_bytes(&with, 64).unwrap();
|
||||
assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after));
|
||||
@@ -588,6 +845,119 @@ mod tests {
|
||||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||||
}
|
||||
|
||||
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
|
||||
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
|
||||
/// heuristic would never look) is used; a matte with an unknown name is ignored.
|
||||
#[test]
|
||||
fn dng_sky_matte_drives_the_sky_mask() {
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
|
||||
let (w, h) = (16u32, 8u32);
|
||||
let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec<u8>| {
|
||||
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![bits; cpp as usize]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] });
|
||||
};
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW]));
|
||||
image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize));
|
||||
let matte = |name: &str, right: bool| {
|
||||
let mut m = IfdBuilder::new();
|
||||
m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK]));
|
||||
m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK]));
|
||||
m.set(t::SEMANTIC_NAME, Value::Ascii(name.into()));
|
||||
image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect());
|
||||
m
|
||||
};
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0]));
|
||||
ifd0.set(t::MAKE, Value::Ascii("Apple".into()));
|
||||
ifd0.set(t::ORIENTATION, Value::Short(vec![6]));
|
||||
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
|
||||
ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21]));
|
||||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB]));
|
||||
image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]);
|
||||
ifd0.add_sub_ifd(raw);
|
||||
ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true));
|
||||
ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false));
|
||||
let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
|
||||
|
||||
let (img, info) = load_bytes(&bytes, 64).unwrap();
|
||||
assert_eq!((img.width, img.height), (8, 16));
|
||||
assert!(info.mattes.is_some());
|
||||
let sky = Mask {
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }],
|
||||
adjust: LocalAdjustments { exposure: -3.0, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let s = DevelopSettings { masks: vec![sky], ..Default::default() };
|
||||
let req = RenderRequest::fit(8, 16);
|
||||
let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32;
|
||||
let plain = DevelopSettings::default();
|
||||
assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top");
|
||||
assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened");
|
||||
// without the matte the heuristic looks at the top of the frame instead
|
||||
let heuristic = SourceInfo { mattes: None, ..info.clone() };
|
||||
assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2));
|
||||
assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13));
|
||||
}
|
||||
|
||||
/// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the
|
||||
/// pipeline the file's colour tags (without the profile look, applied at load) and the white
|
||||
/// the pixels were developed for, and a custom white balance renders a neutral differently
|
||||
/// from the Bradford adaptation it replaces.
|
||||
#[test]
|
||||
fn dng_white_balance_uses_the_camera_colour_model() {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let raw = lightcraft_raw::decode(&dng).unwrap();
|
||||
let (img, info) = load_bytes(&dng, 64).unwrap();
|
||||
let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix");
|
||||
assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
assert!(cc.tags.profile.is_empty());
|
||||
assert_eq!(cc.tags.color_matrix, raw.color.color_matrix);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
(s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0);
|
||||
let req = RenderRequest::fit(32, 32);
|
||||
let camera = render(&img, &info, &s, &req).image;
|
||||
let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image;
|
||||
assert_ne!(camera.data, adapted.data);
|
||||
}
|
||||
|
||||
/// Apple ProRAW: like Lightroom Classic, the default render ignores the file's
|
||||
/// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel
|
||||
/// changes nothing.
|
||||
#[test]
|
||||
fn dng_gain_table_map_is_not_rendered() {
|
||||
use lightcraft_raw::gaintable::GainTableMap;
|
||||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||||
let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
|
||||
raw.color.profile.gain_table_map = Some(GainTableMap {
|
||||
points_v: 1,
|
||||
points_h: 1,
|
||||
points_n: 1,
|
||||
spacing_v: 1.0,
|
||||
spacing_h: 1.0,
|
||||
origin_v: 0.0,
|
||||
origin_h: 0.0,
|
||||
weights: [0.2, 0.2, 0.2, 0.2, 0.2],
|
||||
gamma: 1.0,
|
||||
gains: vec![2.0],
|
||||
});
|
||||
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept");
|
||||
let (after, _) = load_bytes(&with_map, 64).unwrap();
|
||||
assert_eq!(before.data, after.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_preview(48, 32);
|
||||
@@ -656,6 +1026,84 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif
|
||||
/// declares a picture 25x its size.
|
||||
fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] });
|
||||
ifd0.set_child(
|
||||
t::EXIF_IFD,
|
||||
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])),
|
||||
);
|
||||
let mut b = TiffWriter::default().write(&[ifd0]).unwrap();
|
||||
if shell {
|
||||
b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100));
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size
|
||||
/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't
|
||||
/// decode: preview only, with the reason, still showing that image.
|
||||
#[test]
|
||||
fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() {
|
||||
let b = tiff_shell(24, 16, true);
|
||||
let p = probe_bytes("IMG_0001.IIQ", &b).unwrap();
|
||||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16));
|
||||
let why = p.preview_only.expect("marked preview only");
|
||||
assert!(why.contains("600x400") && why.contains("24x16"), "{why}");
|
||||
let (img, src) = load_bytes(&b, 12).unwrap();
|
||||
assert_eq!((img.width, img.height, src.raw), (12, 8, false));
|
||||
assert!(embedded_preview_srgb(&b, 12).is_some());
|
||||
// the same pixels without the private block are a small ordinary image
|
||||
let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap();
|
||||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
|
||||
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
|
||||
}
|
||||
|
||||
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
|
||||
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
|
||||
#[test]
|
||||
fn undecodable_container_with_a_preview_imports_as_preview_only() {
|
||||
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
jpeg[0] = 0x02;
|
||||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||||
f.extend(std::iter::repeat_n(0x11u8, 300));
|
||||
f.extend_from_slice(&jpeg);
|
||||
f.extend(std::iter::repeat_n(0x22u8, 300));
|
||||
let p = probe_bytes("A.MRW", &f).unwrap();
|
||||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30));
|
||||
assert!(p.preview_only.is_some_and(|w| w.contains("Mrw")));
|
||||
let (img, src) = load_bytes(&f, 20).unwrap();
|
||||
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
|
||||
// without a JPEG: a clear error
|
||||
let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err();
|
||||
assert!(e.contains("without an embedded preview"), "{e}");
|
||||
}
|
||||
|
||||
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
|
||||
/// CR3 keeps its own wording.
|
||||
#[test]
|
||||
fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() {
|
||||
use lightcraft_raw::RawError;
|
||||
let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01";
|
||||
assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string()));
|
||||
assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip")));
|
||||
assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok());
|
||||
assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok());
|
||||
assert!(preview_reason(b"x", RawError::NotRaw).is_err());
|
||||
assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 ")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_jobs_show_embedded_previews_then_cached_renders() {
|
||||
use crate::media::QuickSource;
|
||||
|
||||
+393
-73
@@ -33,7 +33,8 @@ use crate::media::ProbeInfo;
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif",
|
||||
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -317,32 +318,96 @@ pub fn is_supported(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
|
||||
/// is never descended into.
|
||||
/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a
|
||||
/// walk that stops at a bound is logged.
|
||||
pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec<String>, top: bool) {
|
||||
let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
|
||||
if (hidden && !top) || skip.is_some_and(|s| p == s) {
|
||||
return;
|
||||
expand_within(paths, skip, crate::walk::Limits::default()).0
|
||||
}
|
||||
|
||||
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
|
||||
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
for p in paths {
|
||||
let p = Path::new(p);
|
||||
if skip.is_some_and(|s| p == s) {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(p) else { return };
|
||||
let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for c in v {
|
||||
walk(&c, skip, out, false);
|
||||
let w = crate::walk::files_in(p, skip, limits, is_supported);
|
||||
if w.truncated {
|
||||
log::warn!(
|
||||
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
|
||||
p.display(),
|
||||
w.entries,
|
||||
limits.max_entries,
|
||||
limits.max_depth
|
||||
);
|
||||
}
|
||||
} else if top || is_supported(p) {
|
||||
truncated |= w.truncated;
|
||||
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
|
||||
} else {
|
||||
// explicitly named files are attempted even with an unknown extension (sniffed)
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
walk(Path::new(p), skip, &mut out, true);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|p| seen.insert(p.clone()));
|
||||
out
|
||||
(out, truncated)
|
||||
}
|
||||
|
||||
/// What a path given on the command line at launch becomes ([`launch_path`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LaunchPath {
|
||||
/// Import this (absolute) file or folder, in the background.
|
||||
Import(String),
|
||||
/// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never
|
||||
/// walked without being chosen in the app (issue #374).
|
||||
Refused { path: String, why: String },
|
||||
}
|
||||
|
||||
/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`);
|
||||
/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is
|
||||
/// resolved against `cwd` — the working folder a launcher picked, often a drive root or
|
||||
/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive
|
||||
/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or
|
||||
/// refused, and so is the home folder `home`: either would walk most of the disk.
|
||||
pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option<PathBuf>) -> Option<LaunchPath> {
|
||||
if arg.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let given = Path::new(arg);
|
||||
let joined = if given.is_absolute() {
|
||||
given.to_path_buf()
|
||||
} else {
|
||||
match cwd {
|
||||
Some(c) => c.join(given),
|
||||
None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }),
|
||||
}
|
||||
};
|
||||
// `.` and `..` resolved by name (no file-system access)
|
||||
let mut abs = PathBuf::new();
|
||||
for c in joined.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
abs.pop();
|
||||
}
|
||||
other => abs.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
let shown = abs.to_string_lossy().to_string();
|
||||
if abs.parent().is_none() {
|
||||
return Some(match dcim(&abs) {
|
||||
Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()),
|
||||
None => LaunchPath::Refused { path: shown, why: "a whole drive".into() },
|
||||
});
|
||||
}
|
||||
let key = lightcraft_catalog::query::folder_key;
|
||||
if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) {
|
||||
return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() });
|
||||
}
|
||||
Some(LaunchPath::Import(shown))
|
||||
}
|
||||
|
||||
/// Probe files (headers + content hash) as an import would.
|
||||
@@ -365,7 +430,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
|
||||
let n = workers(paths.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
@@ -404,18 +469,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
|
||||
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
|
||||
/// byte for byte with the source when there is none); a bad one is removed and the photo
|
||||
/// reported as failed.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the
|
||||
/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and
|
||||
/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte
|
||||
/// with the source when there is none); a bad one is removed and the photo reported as failed.
|
||||
fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
|
||||
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
|
||||
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
@@ -641,6 +704,50 @@ impl Prepared {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Recheck Add-only entries against the owner catalog immediately before commit.
|
||||
/// Preparation may have run while another import added the same path or content.
|
||||
pub fn revalidate_add(&mut self, s: &Session) {
|
||||
let mut paths = HashMap::new();
|
||||
let mut hashes = HashMap::new();
|
||||
for photo in s.catalog.photos() {
|
||||
if let Source::File { path } = &photo.source {
|
||||
paths.insert(normalize_import_path(path), photo.id);
|
||||
}
|
||||
if let Some(hash) = &photo.content_hash {
|
||||
hashes.insert(hash.clone(), photo.id);
|
||||
}
|
||||
}
|
||||
let mut items = Vec::with_capacity(self.items.len());
|
||||
for item in std::mem::take(&mut self.items) {
|
||||
let PreparedItem::Ready(ready) = item else {
|
||||
items.push(item);
|
||||
continue;
|
||||
};
|
||||
let ReadyFile { path, stored, info, sidecar, placed } = *ready;
|
||||
let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied());
|
||||
let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied());
|
||||
if let Some(existing) = existing_path.or(existing_hash) {
|
||||
if let Some(placed) = &placed {
|
||||
crate::import_move::rollback(placed);
|
||||
}
|
||||
items.push(PreparedItem::Duplicate {
|
||||
path,
|
||||
existing: Some(existing),
|
||||
reason: if existing_path.is_some() { "path" } else { "content" },
|
||||
hash: info.content_hash,
|
||||
});
|
||||
} else {
|
||||
items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
}
|
||||
self.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_import_path(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
@@ -699,8 +806,9 @@ impl ImportJob {
|
||||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files — several at once,
|
||||
/// with the names and outcomes of one after another. Nothing in the catalog changes. Stops
|
||||
/// before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
@@ -750,24 +858,39 @@ impl ImportJob {
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
|
||||
// copies or moves — the slow part, reading and writing every byte and verifying it — are
|
||||
// collected and run on several threads (issue #367), then their outcomes take their places.
|
||||
let mut plan = Plan::default();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Failed(path, e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
|
||||
if !opts.local
|
||||
&& let Some(h) = &info.content_hash
|
||||
&& plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h))
|
||||
{
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }));
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
@@ -786,7 +909,6 @@ impl ImportJob {
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
@@ -803,37 +925,35 @@ impl ImportJob {
|
||||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
// the name this file gets depends on what a pending transfer of a like-named file
|
||||
// into the same folder ends up with: that one goes first
|
||||
let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() });
|
||||
let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named));
|
||||
if plan.transfers.iter().any(|t| t.family == family) {
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
How::Place { name: name.unwrap_or(own) }
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
How::Copy { name }
|
||||
};
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG");
|
||||
plan.slots.push(Slot::Transfer);
|
||||
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family });
|
||||
continue;
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
@@ -841,27 +961,199 @@ impl ImportJob {
|
||||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng
|
||||
&& mode == ImportMode::Copy
|
||||
&& stored != path
|
||||
&& info.kind == MediaKind::Raw
|
||||
&& !info.format.eq_ignore_ascii_case("DNG")
|
||||
{
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None }))));
|
||||
}
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the transfers `plan` collected (on several threads) and append its items to `out` in
|
||||
/// file order. Transfers not started because `cancel` was set leave no item.
|
||||
fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) {
|
||||
let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter();
|
||||
for slot in std::mem::take(&mut plan.slots) {
|
||||
let item = match slot {
|
||||
Slot::Item(it) => it,
|
||||
Slot::Transfer => match done.next().flatten() {
|
||||
Some(it) => it,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if let PreparedItem::Ready(r) = &item
|
||||
&& let Some(h) = &r.info.content_hash
|
||||
{
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
out.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
slots: Vec<Slot>,
|
||||
transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
enum Slot {
|
||||
Item(PreparedItem),
|
||||
/// The outcome of the next of [`Plan::transfers`].
|
||||
Transfer,
|
||||
}
|
||||
|
||||
/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]).
|
||||
struct Transfer {
|
||||
path: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
dir: PathBuf,
|
||||
how: How,
|
||||
/// Copy as DNG.
|
||||
dng: bool,
|
||||
/// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's
|
||||
/// names, so they never run at the same time.
|
||||
family: (String, String),
|
||||
}
|
||||
|
||||
enum How {
|
||||
/// A verified copy, under `name` (default: the file's own).
|
||||
Copy { name: Option<String> },
|
||||
/// Move: placed (linked or copied) without touching the source.
|
||||
Place { name: String },
|
||||
}
|
||||
|
||||
/// The names a file called `name` could end up with, as one key: the transfers number a taken name
|
||||
/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`,
|
||||
/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems).
|
||||
fn name_family(name: &str) -> String {
|
||||
let mut stem = match name.rsplit_once('.') {
|
||||
Some((s, _)) if !s.is_empty() => s,
|
||||
_ => name,
|
||||
};
|
||||
while let Some((head, tail)) = stem.rsplit_once('-')
|
||||
&& !head.is_empty()
|
||||
&& !tail.is_empty()
|
||||
&& tail.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
stem = head;
|
||||
}
|
||||
stem.to_lowercase()
|
||||
}
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]):
|
||||
/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few
|
||||
/// enough that each batch soon joins the catalog.
|
||||
pub fn batch_size() -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
workers(usize::MAX).saturating_mul(2).clamp(8, 16)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
8
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one
|
||||
/// not started because `cancel` was set. `done` counts each one finished.
|
||||
fn run_transfers(
|
||||
transfers: Vec<Transfer>,
|
||||
file_bytes: Option<&crate::merge::ByteReader>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
) -> Vec<Option<PreparedItem>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let one = |t: Transfer| {
|
||||
if cancel.load(Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let path = t.path.clone();
|
||||
// last-resort guard: a panic is this file's failure, not the import's
|
||||
let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e));
|
||||
done.fetch_add(1, Relaxed);
|
||||
Some(item)
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = workers(transfers.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<PreparedItem>> = std::iter::repeat_with(|| None).take(transfers.len()).collect();
|
||||
let queue = std::sync::Mutex::new(transfers.into_iter().enumerate());
|
||||
let out = std::sync::Mutex::new(&mut results);
|
||||
// (the tests' injected faults hold for the workers too)
|
||||
#[cfg(test)]
|
||||
let fault = crate::import_move::injected_fault();
|
||||
let work = &|| {
|
||||
#[cfg(test)]
|
||||
crate::import_move::inject(fault);
|
||||
loop {
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some((i, t)) = next else { break };
|
||||
let r = one(t);
|
||||
if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = r;
|
||||
}
|
||||
}
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
let handles: Vec<_> =
|
||||
(0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect();
|
||||
if handles.is_empty() {
|
||||
// no thread to be had: copy here, one after another
|
||||
work();
|
||||
}
|
||||
for h in handles {
|
||||
if h.join().is_err() {
|
||||
log::error!("import: a copy worker stopped unexpectedly");
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
transfers.into_iter().map(one).collect()
|
||||
}
|
||||
|
||||
/// Copy or move one planned file (and Copy as DNG); its item.
|
||||
fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem {
|
||||
let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t;
|
||||
let mut placed = None;
|
||||
let mut stored = match how {
|
||||
How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
};
|
||||
if dng {
|
||||
// decoding the raw twice and encoding it takes several times the file's size in memory
|
||||
let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6);
|
||||
let _permit = crate::memory::work_gate().acquire(weight);
|
||||
match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
@@ -1093,3 +1385,31 @@ impl Session {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revalidate_add_only_converts_matching_ready_item() {
|
||||
let mut s = Session::new();
|
||||
let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg");
|
||||
let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg");
|
||||
let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now");
|
||||
s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap();
|
||||
let ready = |path: &Path| ReadyFile {
|
||||
path: path.to_string_lossy().into(),
|
||||
stored: path.to_string_lossy().into(),
|
||||
info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() },
|
||||
sidecar: None,
|
||||
placed: None,
|
||||
};
|
||||
let mut prepared = Prepared {
|
||||
scanned: 2,
|
||||
items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))],
|
||||
};
|
||||
prepared.revalidate_add(&s);
|
||||
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
|
||||
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ pub(crate) enum Fault {
|
||||
Corrupt,
|
||||
/// Removing the source fails (as on a read-only card).
|
||||
FailRemove,
|
||||
/// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see
|
||||
/// an import's copies run side by side.
|
||||
Slow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) {
|
||||
FAULT.with(|c| c.set(f));
|
||||
}
|
||||
|
||||
/// The calling thread's injected fault (tests only), for the workers it starts to inherit.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn injected_fault() -> Fault {
|
||||
FAULT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// The threads copies made under [`Fault::Slow`] ran on (tests only).
|
||||
#[cfg(test)]
|
||||
static COPY_THREADS: std::sync::Mutex<Vec<std::thread::ThreadId>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn copy_threads() -> Vec<std::thread::ThreadId> {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn injected(f: Fault) -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_pr
|
||||
return Err(io::Error::other("write failed (injected)"));
|
||||
}
|
||||
io::copy(&mut inp, &mut out)?;
|
||||
#[cfg(test)]
|
||||
if injected(Fault::Slow) {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(std::thread::current().id());
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
if injected(Fault::Corrupt) {
|
||||
out.write_all(b"!")?;
|
||||
}
|
||||
|
||||
@@ -10,25 +10,41 @@
|
||||
//! run them off the UI thread.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
// Model/cache types remain available without linking the optional inference crate.
|
||||
#[cfg(not(feature = "denoise"))]
|
||||
extern crate lightcraft_denoise_core as lightcraft_denoise;
|
||||
|
||||
pub mod availability;
|
||||
mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
pub mod cmd;
|
||||
pub mod config;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_index;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_worker;
|
||||
pub mod files;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
mod lightroom_archive;
|
||||
pub mod lightroom_catalog;
|
||||
pub mod lightroom_job;
|
||||
mod lightroom_sqlite;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod model_download;
|
||||
pub mod originals;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
@@ -38,6 +54,7 @@ pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
mod view;
|
||||
pub mod walk;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -83,6 +100,27 @@ pub struct UndoEntry {
|
||||
pub folder: Option<FolderMove>,
|
||||
}
|
||||
|
||||
/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag,
|
||||
/// label, metadata or versions (`depth` bounds nested batches).
|
||||
fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
|
||||
match op {
|
||||
Op::SetDevelop { id, .. }
|
||||
| Op::SetRating { id, .. }
|
||||
| Op::SetFlag { id, .. }
|
||||
| Op::SetLabel { id, .. }
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. } => Some(*id),
|
||||
Op::Batch { ops } if depth < 8 => {
|
||||
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
|
||||
let first = ids.next()??;
|
||||
ids.all(|id| id == Some(first)).then_some(first)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder renamed or moved on disk as part of an undo step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FolderMove {
|
||||
@@ -106,8 +144,10 @@ pub struct Interaction {
|
||||
|
||||
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
|
||||
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub struct Session {
|
||||
library_generation: u64,
|
||||
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
|
||||
/// that changed, nothing else).
|
||||
pub auto_sync: bool,
|
||||
@@ -131,6 +171,17 @@ pub struct Session {
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
|
||||
pub(crate) skip_auto_write: bool,
|
||||
/// AI denoise: the model in use, the photos that have their picture and the work in progress.
|
||||
pub(crate) denoise: denoise::State,
|
||||
/// Where the host keeps face models (one folder each); `None` where there is no file system (the web).
|
||||
pub face_models_dir: Option<std::path::PathBuf>,
|
||||
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.downloads`).
|
||||
pub face_downloads: face_download::Downloads,
|
||||
/// The user's own list of models to download (`catalog.json` in the models folder), as last read.
|
||||
pub(crate) face_catalog: lightcraft_faces::catalog::Catalog,
|
||||
/// The loaded recognition model and the face embeddings made with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) faces: faces_index::FacesState,
|
||||
/// Copied develop settings (partial JSON) for Paste.
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
@@ -165,6 +216,8 @@ pub struct Session {
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
pub library: Option<library::Library>,
|
||||
/// Identity of current library opening; refreshed only after a successful open.
|
||||
pub(crate) library_identity: Arc<()>,
|
||||
/// XMP sidecar preferences (persisted with the library).
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
@@ -219,8 +272,13 @@ impl Default for Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn library_generation(&self) -> u64 {
|
||||
self.library_generation
|
||||
}
|
||||
|
||||
pub fn new() -> Session {
|
||||
Session {
|
||||
library_generation: LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
auto_sync: false,
|
||||
catalog: Catalog::new(),
|
||||
source: LibrarySource::All,
|
||||
@@ -235,6 +293,12 @@ impl Session {
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
denoise: Default::default(),
|
||||
face_models_dir: None,
|
||||
face_downloads: Default::default(),
|
||||
face_catalog: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
faces: Default::default(),
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
@@ -253,6 +317,7 @@ impl Session {
|
||||
segmenter: segment::Segmenter::default(),
|
||||
active_spot: None,
|
||||
library: None,
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
@@ -374,6 +439,27 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The box to cut a face's picture from: the detector's, when the scan has looked at the face (every face is then shown
|
||||
/// equally close, however loosely or tightly its own region was drawn), else the region's own box.
|
||||
pub fn face_view(&self, id: PhotoId, rect: lightcraft_geom::Rect) -> lightcraft_geom::Rect {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(v) = self.faces.index.view(id.0, &rect) {
|
||||
return v;
|
||||
}
|
||||
let _ = id;
|
||||
rect
|
||||
}
|
||||
|
||||
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
|
||||
/// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
|
||||
let fwd = op.clone();
|
||||
self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
|
||||
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
|
||||
/// Saved with the library but not an undo step.
|
||||
@@ -428,6 +514,7 @@ impl Session {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
self.show_undone(&e.op);
|
||||
self.pending_log.push(e.op);
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
@@ -442,11 +529,30 @@ impl Session {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
self.show_undone(&e.op);
|
||||
self.pending_log.push(e.op);
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// After an undo or redo that changed one photo (its edit, rating, flag, label, metadata or
|
||||
/// versions), that photo becomes the active one, as in Lightroom Classic, so the change is on
|
||||
/// screen (issue #293). It joins the selection if it isn't in it; nothing changes for steps
|
||||
/// that touch several photos, albums or files, or for a photo not in the current view.
|
||||
fn show_undone(&mut self, op: &Op) {
|
||||
let Some(id) = single_photo(op, 0) else { return };
|
||||
if self.selection.active == Some(id) || !self.visible().contains(&id) {
|
||||
return;
|
||||
}
|
||||
if !self.selection.contains(id) {
|
||||
self.selection = Selection { ids: vec![id], active: Some(id) };
|
||||
} else {
|
||||
self.selection.active = Some(id);
|
||||
}
|
||||
self.active_mask = None;
|
||||
self.active_spot = None;
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first renaming the step's folder and moving the files its renames
|
||||
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
|
||||
/// library follows them (a logged change outside the undo history), so none goes missing; the
|
||||
@@ -732,10 +838,19 @@ pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_denoise;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_face_models;
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests_face_recognize;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
|
||||
@@ -297,6 +297,12 @@ impl Session {
|
||||
{
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
// Photo ids belong to one library; denoise work must not carry into the next.
|
||||
self.denoise.library_changed();
|
||||
self.media.denoise.clear();
|
||||
// what was learned about the old library's faces is saved, and none of it carries over (photo ids are per library)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.faces.library_changed();
|
||||
let (mut journal, mut catalog, report) = Journal::open(catalog)?;
|
||||
// A loaded catalog counts revisions from 0, like every other one. Caches (sidebar counts,
|
||||
// keyword tree, the grid's list…) are keyed on the revision, so give each library loaded
|
||||
@@ -305,6 +311,7 @@ impl Session {
|
||||
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
|
||||
self.catalog = catalog;
|
||||
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.interaction = None;
|
||||
@@ -395,7 +402,7 @@ impl Session {
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
view_written,
|
||||
settings_warnings: settings.warnings,
|
||||
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
|
||||
warnings_reported: 0,
|
||||
blocked: settings.blocked,
|
||||
lock: lock.take(),
|
||||
@@ -412,6 +419,7 @@ impl Session {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -504,12 +512,16 @@ impl Session {
|
||||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
let result = match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
@@ -570,7 +582,8 @@ impl Session {
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// Settings-file warnings of the open library not handed out yet (the UI shows each once).
|
||||
/// Warnings about the open library not handed out yet: its settings files, and a lock that
|
||||
/// couldn't be taken (the UI shows each once; the CLI and MCP print them on stderr).
|
||||
pub fn take_library_warnings(&mut self) -> Vec<String> {
|
||||
let Some(lib) = self.library.as_mut() else { return vec![] };
|
||||
let new = lib.settings_warnings[lib.warnings_reported..].to_vec();
|
||||
@@ -625,3 +638,15 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning for a library opened without its one-program-at-a-time lock (issue #171): its
|
||||
/// file system can't lock `catalog.lock` (some network shares). It opens anyway, as documented —
|
||||
/// refusing would lock the user out — but the user must learn that a second program could open it.
|
||||
fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
|
||||
lock.filter(|l| !l.held()).map(|_| {
|
||||
"This library could not be locked (its catalog.lock file can't be locked where it is stored, e.g. on some network \
|
||||
shares), so it is open without protection against a second program: use it in one LightCraft app or command at \
|
||||
a time, or changes made in one of them can be lost."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Bounded, deduplicated persistence for Lightroom import source data.
|
||||
//!
|
||||
//! Archives use a deterministic JSON representation wrapped in zlib. The JSON
|
||||
//! writer streams through a bounded compressor, so an import cannot first build
|
||||
//! an unbounded archive buffer. Blob columns are omitted by the catalog reader;
|
||||
//! decoded XMP,
|
||||
//! settings, history, snapshots, identities, paths, collections & membership
|
||||
//! remain available for recovery.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miniz_oxide::deflate::{
|
||||
core::{CompressorOxide, create_comp_flags_from_zip_params},
|
||||
stream::deflate,
|
||||
};
|
||||
use miniz_oxide::{MZFlush, MZStatus};
|
||||
use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
|
||||
/// Maximum size of one complete archive on disk, including format header.
|
||||
pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Maximum total size retained in managed archives.
|
||||
pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Maximum number of managed archives retained.
|
||||
pub const MAX_MANAGED_ARCHIVES: usize = 8;
|
||||
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
const HEADER: &[u8] = b"LC-LRARCH\0\x01Z";
|
||||
const NAME_PREFIX: &str = "lightroom-import-";
|
||||
const NAME_SUFFIX: &str = ".lca";
|
||||
const MAX_SCAN: usize = 1024;
|
||||
const IO_BUFFER: usize = 16 * 1024;
|
||||
|
||||
/// Store compact source data below `directory` and return its managed archive.
|
||||
///
|
||||
/// The operation is safe to call before catalog mutation. It rejects a
|
||||
/// symlink directory, writes only `lightroom-import-<number>.lca`, compares the
|
||||
/// complete bounded byte stream with recent managed archives, and retains at
|
||||
/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy
|
||||
/// `lightroom-import-*.json` files are deliberately ignored and preserved for
|
||||
/// an explicit caller warning.
|
||||
pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String> {
|
||||
prepare_directory(directory)?;
|
||||
let mut managed = managed_files(directory)?;
|
||||
managed.sort_by_key(|entry| entry.index);
|
||||
|
||||
let temporary = temporary_path(directory)?;
|
||||
write_archive(&temporary, data).inspect_err(|_| {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
})?;
|
||||
|
||||
let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES);
|
||||
for entry in recent {
|
||||
let same = match same_bounded(&temporary, &entry.path) {
|
||||
Ok(same) => same,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if same {
|
||||
remove_managed_file(&temporary).map_err(|e| e.to_string())?;
|
||||
let matched = entry.path.clone();
|
||||
let mut all = managed.clone();
|
||||
prune_existing(directory, &mut all, Some(&matched))?;
|
||||
return Ok(matched);
|
||||
}
|
||||
}
|
||||
|
||||
let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1));
|
||||
if managed.last().is_some_and(|entry| entry.index == u64::MAX) {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err("managed Lightroom archive index is exhausted".into());
|
||||
}
|
||||
let destination = match next_destination(directory, index) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let size = match fs::metadata(&temporary) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
let new_entry = Managed { index, path: destination.clone(), size };
|
||||
let mut after = managed;
|
||||
after.push(new_entry);
|
||||
after.sort_by_key(|entry| entry.index);
|
||||
let retirees = match retirement_set(&after) {
|
||||
Ok(retirees) => retirees,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let quarantined = match quarantine(&retirees) {
|
||||
Ok(quarantined) => quarantined,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = publish_new(&temporary, &destination) {
|
||||
rollback_quarantine(&quarantined);
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = remove_managed_file(&temporary) {
|
||||
return Err(format!("archive published but temporary cleanup failed: {error}"));
|
||||
}
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("archive published but could not retire {}: {error}", old.display()));
|
||||
}
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
/// Store source data when possible. An archive that reaches its bounded size is
|
||||
/// omitted with a warning so catalog import can still complete.
|
||||
pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<PathBuf>, String> {
|
||||
match store(directory, data) {
|
||||
Ok(path) => Ok(Some(path)),
|
||||
Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Managed {
|
||||
index: u64,
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn prepare_directory(directory: &Path) -> Result<(), String> {
|
||||
match fs::symlink_metadata(directory) {
|
||||
Ok(meta) => {
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir_all(directory).map_err(|e| e.to_string())?;
|
||||
let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn managed_files(directory: &Path) -> Result<Vec<Managed>, String> {
|
||||
let mut files = Vec::new();
|
||||
for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() {
|
||||
if seen >= MAX_SCAN {
|
||||
return Err(format!("too many entries in archive directory (limit {MAX_SCAN})"));
|
||||
}
|
||||
let entry = result.map_err(|e| e.to_string())?;
|
||||
let Some(index) = parse_managed_name(&entry.file_name()) else { continue };
|
||||
let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
files.push(Managed { index, path: entry.path(), size: meta.len() });
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn parse_managed_name(name: &std::ffi::OsStr) -> Option<u64> {
|
||||
let name = name.to_str()?;
|
||||
let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?;
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn temporary_path(directory: &Path) -> Result<PathBuf, String> {
|
||||
let pid = std::process::id();
|
||||
for slot in 0..MAX_SCAN {
|
||||
let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => continue,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe temporary archive name available".into())
|
||||
}
|
||||
|
||||
fn next_destination(directory: &Path, mut index: u64) -> Result<PathBuf, String> {
|
||||
for _ in 0..MAX_SCAN {
|
||||
let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe managed archive name available".into())
|
||||
}
|
||||
|
||||
fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> {
|
||||
lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| {
|
||||
let mut bounded = BoundedWriter { inner: raw, total: 0 };
|
||||
bounded.write_all(HEADER)?;
|
||||
let mut compressor = ZlibWriter::new(&mut bounded);
|
||||
serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?;
|
||||
compressor.finish()?;
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("could not write Lightroom archive: {error}"))
|
||||
}
|
||||
|
||||
fn json_io(error: serde_json::Error) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
|
||||
}
|
||||
|
||||
struct BoundedWriter<'a> {
|
||||
inner: &'a mut dyn Write,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl Write for BoundedWriter<'_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.inner.write_all(bytes)?;
|
||||
self.total = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB")
|
||||
}
|
||||
|
||||
struct ZlibWriter<'a, 'b> {
|
||||
sink: &'a mut BoundedWriter<'b>,
|
||||
compressor: CompressorOxide,
|
||||
plain: u64,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ZlibWriter<'a, 'b> {
|
||||
fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> {
|
||||
let flags = create_comp_flags_from_zip_params(6, 15, 0);
|
||||
ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 }
|
||||
}
|
||||
|
||||
fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> {
|
||||
let mut rest = input;
|
||||
loop {
|
||||
let mut output = [0u8; IO_BUFFER];
|
||||
let result = deflate(&mut self.compressor, rest, &mut output, flush);
|
||||
self.sink.write_all(&output[..result.bytes_written])?;
|
||||
match result.status {
|
||||
Ok(MZStatus::StreamEnd) => return Ok(()),
|
||||
Ok(MZStatus::Ok) => {
|
||||
let consumed = result.bytes_consumed;
|
||||
if consumed > rest.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress"));
|
||||
}
|
||||
rest = &rest[consumed..];
|
||||
if rest.is_empty() && flush == MZFlush::None {
|
||||
return Ok(());
|
||||
}
|
||||
if consumed == 0 && result.bytes_written == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress"));
|
||||
}
|
||||
}
|
||||
Ok(status) => {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}")));
|
||||
}
|
||||
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.run(&[], MZFlush::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for ZlibWriter<'_, '_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.run(bytes, MZFlush::None)?;
|
||||
self.plain = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn same_bounded(left: &Path, right: &Path) -> io::Result<bool> {
|
||||
let left_meta = fs::symlink_metadata(left)?;
|
||||
let right_meta = fs::symlink_metadata(right)?;
|
||||
if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let left_len = left_meta.len();
|
||||
let right_len = right_meta.len();
|
||||
if left_len != right_len || left_len > MAX_ARCHIVE_BYTES {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut left = File::open(left)?;
|
||||
let mut right = File::open(right)?;
|
||||
let mut left_buf = [0u8; IO_BUFFER];
|
||||
let mut right_buf = [0u8; IO_BUFFER];
|
||||
loop {
|
||||
let left_n = left.read(&mut left_buf)?;
|
||||
let right_n = right.read(&mut right_buf)?;
|
||||
if left_n != right_n {
|
||||
return Ok(false);
|
||||
}
|
||||
if left_n == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
if left_buf[..left_n] != right_buf[..right_n] {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retirement_set(entries: &[Managed]) -> Result<Vec<Managed>, String> {
|
||||
retirement_set_protected(entries, None)
|
||||
}
|
||||
|
||||
fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result<Vec<Managed>, String> {
|
||||
let mut ordered = entries.to_vec();
|
||||
ordered.sort_by_key(|entry| entry.index);
|
||||
let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?;
|
||||
let mut retirees = Vec::new();
|
||||
while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES {
|
||||
let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else {
|
||||
return Err("cannot satisfy archive retention limit".into());
|
||||
};
|
||||
let oldest = ordered.remove(position);
|
||||
total = total.saturating_sub(oldest.size);
|
||||
retirees.push(oldest);
|
||||
}
|
||||
Ok(retirees)
|
||||
}
|
||||
|
||||
fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> {
|
||||
let retirees = retirement_set_protected(entries, protected)?;
|
||||
let quarantined = quarantine(&retirees)?;
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quarantine(entries: &[Managed]) -> Result<Vec<(PathBuf, PathBuf)>, String> {
|
||||
let mut moved = Vec::new();
|
||||
let pid = std::process::id();
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&held) {
|
||||
Ok(_) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("retention temporary path already exists: {}", held.display()));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
let meta = fs::symlink_metadata(&entry.path).map_err(|error| {
|
||||
rollback_quarantine(&moved);
|
||||
error.to_string()
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("managed archive changed into non-file: {}", entry.path.display()));
|
||||
}
|
||||
if let Err(error) = fs::rename(&entry.path, &held) {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
moved.push((entry.path.clone(), held));
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) {
|
||||
for (original, held) in moved.iter().rev() {
|
||||
let _ = fs::rename(held, original);
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(temporary, destination) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_managed_file(path: &Path) -> io::Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path"));
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
struct ArchiveRef<'a>(&'a CatalogImport);
|
||||
|
||||
impl Serialize for ArchiveRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?;
|
||||
out.serialize_field("version", &FORMAT_VERSION)?;
|
||||
out.serialize_field("source", &self.0.source)?;
|
||||
out.serialize_field("photos", &PhotoSeq(&self.0.photos))?;
|
||||
out.serialize_field("collections", &RowSeq(&self.0.collections))?;
|
||||
out.serialize_field("members", &RowSeq(&self.0.members))?;
|
||||
out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?;
|
||||
out.serialize_field("warnings", &self.0.warnings)?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoSeq<'a>(&'a [CatalogPhoto]);
|
||||
impl Serialize for PhotoSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for photo in self.0 {
|
||||
out.serialize_element(&PhotoRef(photo))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoRef<'a>(&'a CatalogPhoto);
|
||||
impl Serialize for PhotoRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?;
|
||||
out.serialize_field("source_id", &p.source_id)?;
|
||||
out.serialize_field("uuid", &p.uuid)?;
|
||||
out.serialize_field("path", &p.path)?;
|
||||
out.serialize_field("image", &RowRef(&p.image))?;
|
||||
out.serialize_field("settings", &p.settings)?;
|
||||
out.serialize_field("xmp", &p.xmp)?;
|
||||
out.serialize_field("keywords", &p.keywords)?;
|
||||
out.serialize_field("history", &RowSeq(&p.history))?;
|
||||
out.serialize_field("snapshots", &RowSeq(&p.snapshots))?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowSeq<'a>(&'a [BTreeMap<String, Value>]);
|
||||
impl Serialize for RowSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for row in self.0 {
|
||||
out.serialize_element(&RowRef(row))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowRef<'a>(&'a BTreeMap<String, Value>);
|
||||
impl Serialize for RowRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_map(None)?;
|
||||
for (key, value) in self.0 {
|
||||
out.serialize_entry(key, value)?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn data(source: String) -> CatalogImport {
|
||||
CatalogImport {
|
||||
source,
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rich_data() -> CatalogImport {
|
||||
let photo = CatalogPhoto {
|
||||
source_id: 7,
|
||||
uuid: "uuid-7".into(),
|
||||
path: "/photos/one.raw".into(),
|
||||
image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]),
|
||||
settings: "s = { Exposure2012 = 0.75 }".into(),
|
||||
xmp: "<xmp>decoded</xmp>".into(),
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
photos: vec![photo],
|
||||
collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])],
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_count(path: &Path) -> usize {
|
||||
fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_identical_deterministic_archives() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
let second = store(&path, &input).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(managed_count(&path), 1);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_returns_archive_that_retention_keeps() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
for index in 1..MAX_MANAGED_ARCHIVES {
|
||||
let filler = path.join(format!("lightroom-import-{index}.lca"));
|
||||
File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap();
|
||||
}
|
||||
let reused = store(&path, &input).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
assert!(reused.is_file());
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_managed_archive_count_and_preserves_unrelated_files() {
|
||||
let path = tempdir();
|
||||
let human = path.join("notes.txt");
|
||||
let legacy = path.join("lightroom-import-0.json");
|
||||
fs::write(&human, b"keep").unwrap();
|
||||
fs::write(&legacy, b"legacy").unwrap();
|
||||
for index in 0..(MAX_MANAGED_ARCHIVES + 3) {
|
||||
let input = data(format!("catalog-{index}.lrcat"));
|
||||
store(&path, &input).unwrap();
|
||||
}
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
assert_eq!(fs::read(&human).unwrap(), b"keep");
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"legacy");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize_before_creating_archive() {
|
||||
let path = tempdir();
|
||||
let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1));
|
||||
assert!(store(&path, &input).is_err());
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_catalog_history_is_best_effort_and_does_not_abort_import() {
|
||||
let path = tempdir();
|
||||
let mut input = data("catalog.lrcat".into());
|
||||
input.photos = (0..5_000)
|
||||
.map(|index| CatalogPhoto {
|
||||
source_id: index,
|
||||
uuid: format!("photo-{index}"),
|
||||
path: format!("/photos/{index}.raw"),
|
||||
image: BTreeMap::new(),
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() {
|
||||
let path = tempdir();
|
||||
let archive = store(&path, &rich_data()).unwrap();
|
||||
let bytes = fs::read(archive).unwrap();
|
||||
assert!(bytes.starts_with(HEADER));
|
||||
let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap();
|
||||
let decoded: Value = serde_json::from_slice(&json).unwrap();
|
||||
assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION));
|
||||
assert_eq!(decoded["source"], serde_json::json!("source.lrcat"));
|
||||
assert_eq!(decoded["photos"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7));
|
||||
assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7"));
|
||||
assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw"));
|
||||
assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }"));
|
||||
assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("<xmp>decoded</xmp>"));
|
||||
assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"]));
|
||||
assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5));
|
||||
assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4]));
|
||||
assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255]));
|
||||
assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers"));
|
||||
assert_eq!(decoded["members"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["members"][0]["image"], serde_json::json!(7));
|
||||
assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["warnings"], serde_json::json!(["one warning"]));
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() {
|
||||
let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64)
|
||||
.map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 })
|
||||
.collect();
|
||||
entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 });
|
||||
let retirees = retirement_set(&entries).unwrap();
|
||||
assert_eq!(retirees.len(), 3);
|
||||
assert_eq!(retirees.iter().map(|entry| entry.index).collect::<Vec<_>>(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_failure_preserves_old_archive() {
|
||||
let path = tempdir();
|
||||
let old = path.join("lightroom-import-0.lca");
|
||||
let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id()));
|
||||
fs::write(&old, b"old archive").unwrap();
|
||||
fs::write(&held, b"occupied").unwrap();
|
||||
let entry = Managed { index: 0, path: old.clone(), size: 11 };
|
||||
assert!(quarantine(&[entry]).is_err());
|
||||
assert_eq!(fs::read(&old).unwrap(), b"old archive");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user