Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fad846cd1 | ||
|
|
5c26dc5033 | ||
|
|
abefb94daa | ||
|
|
54772f94d6 | ||
|
|
65c1f1a468 | ||
|
|
cb7ad0bbe7 | ||
|
|
eae720ce75 | ||
|
|
c02b401a9a | ||
|
|
f6a3f3f4e2 | ||
|
|
05ac2416c6 | ||
|
|
0b06e31bf3 | ||
|
|
d5c93ae795 | ||
|
|
379dd1afcc | ||
|
|
825c5af20a | ||
|
|
23a2f13b46 | ||
|
|
2c947430e6 | ||
|
|
36556729f1 | ||
|
|
6f33517b35 | ||
|
|
1d7115437b | ||
|
|
caae65c78d | ||
|
|
fcccc2c2e0 | ||
|
|
1bc04870c3 | ||
|
|
4da2ec39b3 | ||
|
|
9558b77759 |
Generated
+25
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ members = [
|
|||||||
exclude = ["third_party"]
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.19.1"
|
version = "0.20.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -83,28 +83,125 @@ texture directly — no readback between the GPU and the screen.
|
|||||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
|
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
|
||||||
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
|
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
|
||||||
|
|
||||||
Or build it. Git LFS is required for the model weights, and the toolchain
|
## Building from source
|
||||||
pins itself to 1.92.0:
|
|
||||||
|
**Before anything.** Git LFS holds the model weights and the manual's
|
||||||
|
pictures; a clone without it has ~130-byte pointers in their place, and every
|
||||||
|
packager below refuses to ship one. The Rust toolchain pins itself to 1.92.0
|
||||||
|
through `rust-toolchain.toml`, so rustup is all you install. Slint needs a few
|
||||||
|
system headers, and the app needs a Vulkan driver at runtime:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
|
||||||
git lfs install && git lfs pull
|
git lfs install && git lfs pull
|
||||||
|
|
||||||
|
# Debian / Ubuntu
|
||||||
|
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libvulkan1
|
||||||
|
# Arch
|
||||||
|
sudo pacman -S --needed pkgconf fontconfig libxkbcommon vulkan-icd-loader
|
||||||
|
```
|
||||||
|
|
||||||
|
**To try it** from the checkout, without installing anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
cargo run --release -p darkroom-desktop
|
cargo run --release -p darkroom-desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
|
This is for development. The binary under `target/` finds no face, scene or
|
||||||
|
panorama-fill models, and a release build does not find the manual either:
|
||||||
|
it looks for all of them in the system data directories an install creates
|
||||||
|
(`$XDG_DATA_DIRS/darkroom`, by default `/usr/local/share/darkroom` and
|
||||||
|
`/usr/share/darkroom`), never in the checkout. Those features show as
|
||||||
|
unavailable until it is installed.
|
||||||
|
|
||||||
|
### Linux: build and install
|
||||||
|
|
||||||
|
**On Arch**, build a package from the checkout and install it with pacman,
|
||||||
|
so it can be upgraded and removed like any other:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
cd packaging && makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
|
**Elsewhere**, build the release binary and install it under `/usr/local`
|
||||||
|
by hand. These are the same files, in the same places, as the Arch package
|
||||||
|
([`packaging/PKGBUILD`](packaging/PKGBUILD)'s `package()` is the reference):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --locked -p darkroom-desktop
|
||||||
|
# -> target/release/darkroom-desktop
|
||||||
|
|
||||||
|
P=/usr/local
|
||||||
|
sudo install -Dm755 target/release/darkroom-desktop $P/bin/darkroom-desktop
|
||||||
|
|
||||||
|
# The models: faces and eye state, scene categories, panorama border fill
|
||||||
|
sudo install -d $P/share/darkroom/models
|
||||||
|
sudo install -m644 models/face/*.onnx models/scene/* models/inpaint/*.onnx \
|
||||||
|
$P/share/darkroom/models/
|
||||||
|
|
||||||
|
# The offline manual the Help menu opens
|
||||||
|
sudo install -Dm644 docs/manual/index.html $P/share/darkroom/manual/index.html
|
||||||
|
sudo install -Dm644 -t $P/share/darkroom/manual/media docs/manual/media/*
|
||||||
|
|
||||||
|
# Launcher entry, icon and software-centre description
|
||||||
|
sudo install -Dm644 packaging/paris.tourolle.darkroom.desktop \
|
||||||
|
$P/share/applications/paris.tourolle.darkroom.desktop
|
||||||
|
sudo install -Dm644 ui/dr-ui/ui/app-icon.png \
|
||||||
|
$P/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
|
||||||
|
sudo install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml \
|
||||||
|
$P/share/metainfo/paris.tourolle.darkroom.metainfo.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `darkroom-desktop`, or open it from the application menu. To
|
||||||
|
uninstall, remove those files and `/usr/local/share/darkroom`. Your catalog,
|
||||||
|
settings and thumbnails live in `darkroom/` under your own XDG data, config
|
||||||
|
and cache directories (`~/.local/share`, `~/.config`, `~/.cache`) and are
|
||||||
|
not touched by either.
|
||||||
|
|
||||||
|
Optional at runtime: `gnome-keyring` or `kwallet` to remember Nextcloud
|
||||||
|
credentials, and an ONNX Runtime in `/usr/lib` (CPU, or ROCm on an AMD GPU) to
|
||||||
|
run the models on every core rather than on the built-in engine.
|
||||||
|
|
||||||
|
### Windows: build the installer
|
||||||
|
|
||||||
|
The `.exe` is cross-built from Linux in a container (podman or docker), with
|
||||||
|
no Windows machine involved. Two steps — the executable, then the NSIS
|
||||||
|
installer that carries it with its models and manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
./docker/windows/build.sh docker/windows/package.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Both land in the container's cache on the host, `~/.cache/darkroom-windows/target/`:
|
||||||
|
the bare executable under `x86_64-pc-windows-gnu/release/darkroom-desktop.exe`,
|
||||||
|
the installer under `installer/DarkRoom-<version>-x86_64-setup.exe`.
|
||||||
|
Copy that to the Windows machine and run it — it installs per user, needs no
|
||||||
|
administrator rights, and adds an uninstaller. Run on its own, the bare
|
||||||
|
`.exe` looks for `models\` and `manual\` beside itself, so use the installer.
|
||||||
|
[docker/windows](docker/windows/README.md) has the details.
|
||||||
|
|
||||||
|
### Android: build the APK
|
||||||
|
|
||||||
|
Also containerised ([docker/android](docker/android/README.md)). This
|
||||||
|
builds, packages and debug-signs the APK, and with `--install` puts it on a
|
||||||
|
device connected over adb:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker/android/package.sh --install
|
||||||
|
```
|
||||||
|
|
||||||
|
A debug-signed APK cannot replace one installed from a release; uninstall
|
||||||
|
that first.
|
||||||
|
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) has the four
|
||||||
commands CI runs against what you send, and the shortest useful
|
commands CI runs against what you send, and the shortest useful
|
||||||
contribution — a develop operation is one YAML file, and it arrives with its
|
contribution — a develop operation is one YAML file, and it arrives with its
|
||||||
controls, its place in the chain and its tests.
|
controls, its place in the chain and its tests.
|
||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.19.1**, thirty tagged releases in. 193 numbered requirements in
|
**0.20.0**, thirty-four tagged releases in. 193 numbered requirements in
|
||||||
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||||
the rest are written down rather than merely absent.
|
the rest are written down rather than merely absent.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||||
here is a distribution manifest yet. Only network access is declared: file
|
here is a distribution manifest yet. The library grid needs no storage
|
||||||
access needs no manifest permission because the library grid reads through
|
permission, because it reads through SAF, which grants per-tree at runtime
|
||||||
SAF, which grants per-tree at runtime (ARCH §6.9).
|
(ARCH §6.9); the one storage permission declared is for importing from a
|
||||||
|
camera card, which is read by path.
|
||||||
|
|
||||||
Minimal is not the same as empty, and the entries below that are not the
|
Minimal is not the same as empty, and the entries below that are not the
|
||||||
activity are the difference. A manifest is the only place a component can be
|
activity are the difference. A manifest is the only place a component can be
|
||||||
@@ -21,13 +22,29 @@
|
|||||||
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||||
socket creation outright, and the failure is invisible — no panic to
|
socket creation outright, and the failure is invisible — no panic to
|
||||||
catch, no log line, just a worker thread that stops. Storage is the
|
catch, no log line, just a worker thread that stops. Storage is the
|
||||||
separate case that genuinely needs no permission here, because SAF
|
separate case: the library and album folders need no permission
|
||||||
grants per-tree at runtime (ARCH §6.9). -->
|
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||||
work on unmetered-and-charging, which means knowing the network type. -->
|
work on unmetered-and-charging, which means knowing the network type. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
|
||||||
|
as files, and "all files access" is what makes an SD card or a USB
|
||||||
|
card reader readable by path on API 30 and up (see Cards.java). It is
|
||||||
|
granted on a system settings page, not a dialog; the import page
|
||||||
|
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
|
||||||
|
same thing for API 28 and 29, and means nothing above them; on 29 it
|
||||||
|
reads by path only with requestLegacyExternalStorage, which is why
|
||||||
|
<application> carries that flag.
|
||||||
|
|
||||||
|
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
|
||||||
|
kinds. DarkRoom is not distributed through Play. -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
|
||||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||||
fails at install rather than at first frame. -->
|
fails at install rather than at first frame. -->
|
||||||
@@ -53,6 +70,7 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:hasCode="true"
|
android:hasCode="true"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
|
|
||||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package paris.tourolle.darkroom;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.os.storage.StorageManager;
|
||||||
|
import android.os.storage.StorageVolume;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding a camera card, and the permission that makes it readable (FR-CAT-10).
|
||||||
|
*
|
||||||
|
* <p>An import reads the card as files: the survey walks it, the probe reads
|
||||||
|
* each header and the copy streams each original, all through the same
|
||||||
|
* {@code std::fs} code the desktop uses. Android hands out such paths —
|
||||||
|
* {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access"
|
||||||
|
* ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD
|
||||||
|
* card and of a USB card reader. Below API 30 the same paths are readable
|
||||||
|
* with {@code READ_EXTERNAL_STORAGE}.
|
||||||
|
*
|
||||||
|
* <p>Not the folder picker {@link FolderPicker} uses for albums. A tree
|
||||||
|
* granted through SAF is {@code content://} URIs, not paths, and since API 30
|
||||||
|
* the picker refuses the root of a card outright; reading a card through it
|
||||||
|
* would mean a second storage implementation under the importer, where this
|
||||||
|
* needs none.
|
||||||
|
*
|
||||||
|
* <p>Google Play restricts this permission to file managers and the like.
|
||||||
|
* DarkRoom is not distributed through Play, so the restriction does not
|
||||||
|
* apply; it would need revisiting if that changed.
|
||||||
|
*/
|
||||||
|
public final class Cards {
|
||||||
|
private static final String TAG = "DarkRoom";
|
||||||
|
|
||||||
|
private Cards() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this app may read a card's files by path. */
|
||||||
|
public static boolean hasAccess(Context context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
return Environment.isExternalStorageManager();
|
||||||
|
}
|
||||||
|
return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system page where the user grants it.
|
||||||
|
*
|
||||||
|
* <p>A settings page rather than a permission dialog because there is no
|
||||||
|
* dialog for this one on API 30 and up: the user flips "Allow access to
|
||||||
|
* manage all files" for this app. Below 30 the context is the application
|
||||||
|
* context, which cannot raise a runtime permission request (that needs an
|
||||||
|
* Activity's result), so the app's own settings page is the route there
|
||||||
|
* too. Either way the app learns of the grant by asking
|
||||||
|
* {@link #hasAccess} again.
|
||||||
|
*/
|
||||||
|
public static void requestAccess(Context context) {
|
||||||
|
Uri self = Uri.parse("package:" + context.getPackageName());
|
||||||
|
Intent intent;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self);
|
||||||
|
} else {
|
||||||
|
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self);
|
||||||
|
}
|
||||||
|
// The context is not an Activity; see FolderPicker.start.
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
try {
|
||||||
|
context.startActivity(intent);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Some builds ship without the per-app page; the list of every
|
||||||
|
// app holding the permission is the fallback that always exists.
|
||||||
|
Log.w(TAG, "no per-app all-files page; opening the list", e);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
|
||||||
|
list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
context.startActivity(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every mounted volume other than the device's own storage.
|
||||||
|
*
|
||||||
|
* <p>One string per volume, {@code path \t description \t removable},
|
||||||
|
* where removable is {@code 1} or {@code 0}: the reason {@link Intents}
|
||||||
|
* gives for keeping the JNI surface to strings. The primary volume is left
|
||||||
|
* out — it is the device's internal storage, never a card — and so is
|
||||||
|
* anything not mounted, which is a card being ejected or one the system
|
||||||
|
* could not read.
|
||||||
|
*/
|
||||||
|
public static String[] volumes(Context context) {
|
||||||
|
List<String> out = new ArrayList<String>();
|
||||||
|
StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
|
||||||
|
if (manager == null) {
|
||||||
|
return new String[0];
|
||||||
|
}
|
||||||
|
for (StorageVolume volume : manager.getStorageVolumes()) {
|
||||||
|
if (volume.isPrimary()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String state = volume.getState();
|
||||||
|
if (!Environment.MEDIA_MOUNTED.equals(state)
|
||||||
|
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String path = path(volume);
|
||||||
|
if (path == null) {
|
||||||
|
Log.w(TAG, "a mounted volume with no path: " + volume);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String description = volume.getDescription(context);
|
||||||
|
if (description == null) {
|
||||||
|
description = new File(path).getName();
|
||||||
|
}
|
||||||
|
out.add(path + "\t" + description.replace('\t', ' ') + "\t"
|
||||||
|
+ (volume.isRemovable() ? "1" : "0"));
|
||||||
|
}
|
||||||
|
return out.toArray(new String[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the volume is mounted.
|
||||||
|
*
|
||||||
|
* <p>{@code getDirectory} is API 30. Below it the same answer is the
|
||||||
|
* hidden {@code getPath}, which every release from 24 to 29 has, reached by
|
||||||
|
* reflection because android.jar does not declare it.
|
||||||
|
*/
|
||||||
|
private static String path(StorageVolume volume) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
File dir = volume.getDirectory();
|
||||||
|
return dir == null ? null : dir.getPath();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object path = StorageVolume.class.getMethod("getPath").invoke(volume);
|
||||||
|
return path == null ? null : path.toString();
|
||||||
|
} catch (ReflectiveOperationException e) {
|
||||||
|
Log.w(TAG, "StorageVolume.getPath", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use dr_catalog::{keywords, rating, schema, Catalog};
|
use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
@@ -108,6 +108,9 @@ fn main() {
|
|||||||
time(" keywords::adopt_orphan_terms", 20, || {
|
time(" keywords::adopt_orphan_terms", 20, || {
|
||||||
keywords::adopt_orphan_terms(conn).unwrap();
|
keywords::adopt_orphan_terms(conn).unwrap();
|
||||||
});
|
});
|
||||||
|
time(" name_dates::fill", 20, || {
|
||||||
|
name_dates::fill(conn, None).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
interactive(conn);
|
interactive(conn);
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,48 @@ pub fn record_exports(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every file name an album records, for an export choosing a name to know
|
||||||
|
/// what it would land on.
|
||||||
|
///
|
||||||
|
/// A server album cannot be asked while the export is queued offline, and
|
||||||
|
/// the names this app put there are the ones a second export of the same
|
||||||
|
/// photographs will collide with. One read of the album's rows, not one per
|
||||||
|
/// candidate name.
|
||||||
|
pub fn file_names(
|
||||||
|
conn: &Connection,
|
||||||
|
id: AlbumId,
|
||||||
|
) -> Result<std::collections::HashSet<String>, CatalogError> {
|
||||||
|
ensure_tables(conn)?;
|
||||||
|
let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([id.0 as i64], |r| r.get(0))?
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file the upload had to give another name: the server held one by the
|
||||||
|
/// name the export recorded, put there by something this catalog never
|
||||||
|
/// saw. The album row follows the file to the name it was given.
|
||||||
|
///
|
||||||
|
/// By the album's server folder, because that is all an outbox entry knows.
|
||||||
|
/// `folder` is spelled as [`Place::Server`] spells it, without slashes at
|
||||||
|
/// either end.
|
||||||
|
pub fn rename_export(
|
||||||
|
conn: &Connection,
|
||||||
|
folder: &str,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
) -> Result<(), CatalogError> {
|
||||||
|
ensure_tables(conn)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE OR REPLACE album_exports SET file_name = ?3
|
||||||
|
WHERE file_name = ?2
|
||||||
|
AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)",
|
||||||
|
rusqlite::params![folder.trim_matches('/'), from, to],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The photographs behind an album's files, most recently exported first —
|
/// The photographs behind an album's files, most recently exported first —
|
||||||
/// what the grid shows when the album is opened.
|
/// what the grid shows when the album is opened.
|
||||||
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
|
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
|
||||||
@@ -463,6 +505,28 @@ mod tests {
|
|||||||
assert_eq!(sources(conn, album).unwrap(), vec![b]);
|
assert_eq!(sources(conn, album).unwrap(), vec![b]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_renamed_upload_moves_the_row_of_the_server_album_only() {
|
||||||
|
let cat = catalog();
|
||||||
|
let conn = cat.connection();
|
||||||
|
let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap();
|
||||||
|
let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap();
|
||||||
|
let a = image(conn, "a.cr3");
|
||||||
|
record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap();
|
||||||
|
record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap();
|
||||||
|
|
||||||
|
rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
file_names(conn, web).unwrap(),
|
||||||
|
["a-1.jpg".to_string()].into()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file_names(conn, other).unwrap(),
|
||||||
|
["a.jpg".to_string()].into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn moving_to_the_server_forgets_the_local_folder() {
|
fn moving_to_the_server_forgets_the_local_folder() {
|
||||||
let cat = catalog();
|
let cat = catalog();
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub mod faces;
|
|||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod keywords;
|
pub mod keywords;
|
||||||
pub mod merge;
|
pub mod merge;
|
||||||
|
pub mod name_dates;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod rating;
|
pub mod rating;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
//! TRACES: FR-CAT-5
|
||||||
|
//! A capture time read from the file's name, for an image whose header has
|
||||||
|
//! none.
|
||||||
|
//!
|
||||||
|
//! # Why
|
||||||
|
//!
|
||||||
|
//! A photograph with no EXIF date sorts after everything else, so it is lost
|
||||||
|
//! at the end of the grid and absent from the timeline. The files that end up
|
||||||
|
//! there are rarely without a date — they are without *EXIF*: WhatsApp strips
|
||||||
|
//! every tag and names the file `WhatsApp Image 2023-06-15 at 07.00.42.jpeg`,
|
||||||
|
//! a Windows Phone wrote `WP_20140922_14_16_27_Pro.jpg`, a phone camera
|
||||||
|
//! `IMG_20190812_153012.jpg`, and darktable's import renames to
|
||||||
|
//! `20230629_0001.jpeg`. On the reference library 250 of 274 undated images
|
||||||
|
//! carried their date in the name or in the folder above it.
|
||||||
|
//!
|
||||||
|
//! # What is accepted
|
||||||
|
//!
|
||||||
|
//! A date is `YYYYMMDD` as a whole run of digits, or `YYYY`, `MM` and `DD`
|
||||||
|
//! joined by `-`, `_` or `.`. A time may follow it — `HHMMSS` as one run (or
|
||||||
|
//! nine digits, milliseconds appended), or three two-digit runs joined by
|
||||||
|
//! `-`, `_`, `.` or `:` — after `_`, `-`, `.`, `T`, a space or ` at `.
|
||||||
|
//! Anything else after the date leaves it at midnight: `_0059` in
|
||||||
|
//! `20230628_0059` is a sequence number, not 00:59, and reading it as a time
|
||||||
|
//! would invent one.
|
||||||
|
//!
|
||||||
|
//! The name is tried first and then each folder above it, innermost first —
|
||||||
|
//! `2016/2016-11-11/IMG_7910.jpg` is dated by its folder. A bare year folder
|
||||||
|
//! is not a date: putting a photograph at 1 January is a wrong answer, and an
|
||||||
|
//! undated one at least says it does not know.
|
||||||
|
//!
|
||||||
|
//! The reading is wall-clock time with no zone, stored as EXIF's is
|
||||||
|
//! (`dr_decode::parse_exif_datetime`), and EXIF always wins: this only fills
|
||||||
|
//! rows whose `captured_at` is still empty.
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use crate::CatalogError;
|
||||||
|
|
||||||
|
/// The capture time a path's name states, as wall-clock Unix seconds.
|
||||||
|
pub fn date_from_path(source_ref: &str) -> Option<i64> {
|
||||||
|
let mut parts = source_ref.rsplit(['/', '\\']);
|
||||||
|
let name = parts.next()?;
|
||||||
|
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
|
||||||
|
date_in(stem).or_else(|| parts.find_map(date_in))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date every examined, undated image whose name states one.
|
||||||
|
///
|
||||||
|
/// `only` limits the pass to the images just examined — what the sweep hands
|
||||||
|
/// in — and `None` visits every undated image, which is the backfill's case.
|
||||||
|
/// Both read the undated side alone (`images_captured` answers
|
||||||
|
/// `captured_at IS NULL` with a seek), never the library.
|
||||||
|
///
|
||||||
|
/// Returns how many images were dated.
|
||||||
|
pub fn fill(conn: &Connection, only: Option<&[i64]>) -> Result<usize, CatalogError> {
|
||||||
|
let rows: Vec<(i64, String)> = match only {
|
||||||
|
None => {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, source_ref FROM images
|
||||||
|
WHERE captured_at IS NULL AND metadata_state >= 2",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
Some(ids) => {
|
||||||
|
let mut stmt = conn.prepare_cached(
|
||||||
|
"SELECT source_ref FROM images
|
||||||
|
WHERE id = ?1 AND captured_at IS NULL AND metadata_state >= 2",
|
||||||
|
)?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for &id in ids {
|
||||||
|
let mut q = stmt.query([id])?;
|
||||||
|
if let Some(r) = q.next()? {
|
||||||
|
rows.push((id, r.get(0)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dated: Vec<(i64, i64)> = rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, path)| date_from_path(path).map(|at| (*id, at)))
|
||||||
|
.collect();
|
||||||
|
if dated.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A savepoint rather than a transaction, so a caller already inside one
|
||||||
|
// can still call this: the backfill's 250 rows are one commit, not 250.
|
||||||
|
conn.execute_batch("SAVEPOINT name_dates")?;
|
||||||
|
let written = (|| {
|
||||||
|
let mut stmt = conn.prepare_cached(
|
||||||
|
"UPDATE images SET captured_at = ?2 WHERE id = ?1 AND captured_at IS NULL",
|
||||||
|
)?;
|
||||||
|
let mut n = 0;
|
||||||
|
for (id, at) in &dated {
|
||||||
|
n += stmt.execute(rusqlite::params![id, at])?;
|
||||||
|
}
|
||||||
|
Ok::<_, CatalogError>(n)
|
||||||
|
})();
|
||||||
|
match written {
|
||||||
|
Ok(n) => {
|
||||||
|
conn.execute_batch("RELEASE name_dates")?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = conn.execute_batch("ROLLBACK TO name_dates; RELEASE name_dates");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first date, with its time if one follows, in one name component.
|
||||||
|
fn date_in(s: &str) -> Option<i64> {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < b.len() {
|
||||||
|
// Only at the start of a run of digits: a date inside a longer number
|
||||||
|
// is a coincidence, not a date.
|
||||||
|
if b[i].is_ascii_digit() && (i == 0 || !b[i - 1].is_ascii_digit()) {
|
||||||
|
if let Some(at) = date_at(b, i) {
|
||||||
|
return Some(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A date starting at `i`, and the time after it if there is one.
|
||||||
|
fn date_at(b: &[u8], i: usize) -> Option<i64> {
|
||||||
|
let run = digits(b, i);
|
||||||
|
let ((y, mo, d), after) = match run.len() {
|
||||||
|
// YYYYMMDD, or YYYYMMDDHHMMSS written as one number.
|
||||||
|
8 | 14 => ((num(&run[..4]), num(&run[4..6]), num(&run[6..8])), i + 8),
|
||||||
|
4 => {
|
||||||
|
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.'));
|
||||||
|
let mo_at = i + 4 + 1;
|
||||||
|
let d_at = mo_at + 2 + 1;
|
||||||
|
if !(sep(i + 4) && digits(b, mo_at).len() == 2 && sep(mo_at + 2))
|
||||||
|
|| digits(b, d_at).len() != 2
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
(num(run), num(&b[mo_at..mo_at + 2]), num(&b[d_at..d_at + 2])),
|
||||||
|
d_at + 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let day = civil_days(y, mo, d)?;
|
||||||
|
|
||||||
|
let time = if run.len() == 14 {
|
||||||
|
hms(num(&run[8..10]), num(&run[10..12]), num(&run[12..14]))
|
||||||
|
} else {
|
||||||
|
time_at(b, after)
|
||||||
|
};
|
||||||
|
Some(day * 86_400 + time.unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time following a date that ends at `i`, as seconds into the day.
|
||||||
|
fn time_at(b: &[u8], i: usize) -> Option<i64> {
|
||||||
|
let rest = &b[i..];
|
||||||
|
let start = if rest.starts_with(b" at ") {
|
||||||
|
i + 4
|
||||||
|
} else if matches!(rest.first(), Some(b'_' | b'-' | b'.' | b'T' | b' ')) {
|
||||||
|
i + 1
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let run = digits(b, start);
|
||||||
|
match run.len() {
|
||||||
|
// HHMMSS, or with milliseconds appended (Pixel's PXL_…_123456789).
|
||||||
|
6 | 9 => hms(num(&run[..2]), num(&run[2..4]), num(&run[4..6])),
|
||||||
|
2 => {
|
||||||
|
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.' | b':'));
|
||||||
|
let (m_at, s_at) = (start + 3, start + 6);
|
||||||
|
if !(sep(start + 2) && digits(b, m_at).len() == 2 && sep(m_at + 2))
|
||||||
|
|| digits(b, s_at).len() != 2
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
hms(num(run), num(&b[m_at..m_at + 2]), num(&b[s_at..s_at + 2]))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run of ASCII digits starting at `i`.
|
||||||
|
fn digits(b: &[u8], i: usize) -> &[u8] {
|
||||||
|
let rest = b.get(i..).unwrap_or(&[]);
|
||||||
|
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
|
||||||
|
&rest[..n]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(d: &[u8]) -> i64 {
|
||||||
|
d.iter().fold(0, |n, c| n * 10 + i64::from(c - b'0'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hms(h: i64, m: i64, s: i64) -> Option<i64> {
|
||||||
|
((0..24).contains(&h) && (0..60).contains(&m) && (0..61).contains(&s))
|
||||||
|
.then_some(h * 3_600 + m * 60 + s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since 1970-01-01 for a valid civil date, `None` for anything else.
|
||||||
|
///
|
||||||
|
/// The year range is EXIF's (`parse_exif_datetime`): wide enough for scanned
|
||||||
|
/// film, narrow enough that a counter such as `12345678` is not a date.
|
||||||
|
fn civil_days(y: i64, mo: i64, d: i64) -> Option<i64> {
|
||||||
|
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
|
||||||
|
let month_len = match mo {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if leap => 29,
|
||||||
|
2 => 28,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if !(1900..=2200).contains(&y) || !(1..=month_len).contains(&d) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let y_adj = if mo <= 2 { y - 1 } else { y };
|
||||||
|
let era = y_adj.div_euclid(400);
|
||||||
|
let yoe = y_adj - era * 400;
|
||||||
|
let mp = (mo + 9) % 12;
|
||||||
|
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
Some(era * 146_097 + doe - 719_468)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Wall-clock seconds for a date and time, the expected side of each case.
|
||||||
|
fn at(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> Option<i64> {
|
||||||
|
Some(civil_days(y, mo, d).unwrap() * 86_400 + h * 3_600 + mi * 60 + s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_names_in_the_reference_library_are_read() {
|
||||||
|
// Every shape here is a file that sat undated at the end of the grid.
|
||||||
|
for (path, want) in [
|
||||||
|
(
|
||||||
|
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-15 at 07.00.42.jpeg",
|
||||||
|
at(2023, 6, 15, 7, 0, 42),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-17 at 12.45.52 (1).jpeg",
|
||||||
|
at(2023, 6, 17, 12, 45, 52),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PhotosRaw/WP_20140922_14_16_27_Pro.jpg",
|
||||||
|
at(2014, 9, 22, 14, 16, 27),
|
||||||
|
),
|
||||||
|
// A sequence number after the date is not a time.
|
||||||
|
(
|
||||||
|
"PhotosRaw/Darktable/20230629_no_name/20230629_0001.jpeg",
|
||||||
|
at(2023, 6, 29, 0, 0, 0),
|
||||||
|
),
|
||||||
|
("PhotosRaw/20230628_0059.jpg", at(2023, 6, 28, 0, 0, 0)),
|
||||||
|
(
|
||||||
|
"PhotosRaw/backdrops/IMG_20130625_0021.jpg",
|
||||||
|
at(2013, 6, 25, 0, 0, 0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PhotosRaw/alps trip/20230628_0641 - 20230628_0661.jpg",
|
||||||
|
at(2023, 6, 28, 0, 0, 0),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(date_from_path(path), want, "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn common_camera_and_app_names_are_read() {
|
||||||
|
for (path, want) in [
|
||||||
|
("IMG_20190812_153012.jpg", at(2019, 8, 12, 15, 30, 12)),
|
||||||
|
("PXL_20210101_123456789.jpg", at(2021, 1, 1, 12, 34, 56)),
|
||||||
|
(
|
||||||
|
"Screenshot_2021-03-04-12-30-45.png",
|
||||||
|
at(2021, 3, 4, 12, 30, 45),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Screenshot from 2021-03-04 12-30-45.png",
|
||||||
|
at(2021, 3, 4, 12, 30, 45),
|
||||||
|
),
|
||||||
|
("IMG-20210304-WA0001.jpg", at(2021, 3, 4, 0, 0, 0)),
|
||||||
|
("20210304143012.jpg", at(2021, 3, 4, 14, 30, 12)),
|
||||||
|
("2019.12.25 party.jpg", at(2019, 12, 25, 0, 0, 0)),
|
||||||
|
("signal-2022-01-02-101112.jpg", at(2022, 1, 2, 10, 11, 12)),
|
||||||
|
("2022-01-02T10:11:12.jpg", at(2022, 1, 2, 10, 11, 12)),
|
||||||
|
] {
|
||||||
|
assert_eq!(date_from_path(path), want, "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_dates_a_name_that_does_not() {
|
||||||
|
assert_eq!(
|
||||||
|
date_from_path("PhotosRaw/2016/2016-11-11/IMG_7910.jpg"),
|
||||||
|
at(2016, 11, 11, 0, 0, 0)
|
||||||
|
);
|
||||||
|
// The innermost folder that states a date wins.
|
||||||
|
assert_eq!(
|
||||||
|
date_from_path("2016-01-01 trip/2016-01-03/_MG_1.jpg"),
|
||||||
|
at(2016, 1, 3, 0, 0, 0)
|
||||||
|
);
|
||||||
|
// The name beats its folder.
|
||||||
|
assert_eq!(
|
||||||
|
date_from_path("2016-11-11/IMG_20161112_080000.jpg"),
|
||||||
|
at(2016, 11, 12, 8, 0, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbers_that_are_not_dates_are_left_alone() {
|
||||||
|
for path in [
|
||||||
|
"PhotosRaw/_MG_9002.jpg",
|
||||||
|
"PhotosRaw/scanning/fau_2.jpg",
|
||||||
|
// A year folder is not a day.
|
||||||
|
"PhotosRaw/2016/_MG_1.jpg",
|
||||||
|
"IMG_1999.jpg",
|
||||||
|
"DSC_12345678.jpg", // month 56
|
||||||
|
"20230230_0001.jpg", // 30 February
|
||||||
|
"120230615.jpg", // the date is inside a longer number
|
||||||
|
"1612345678901.jpg", // a millisecond epoch, not a civil date
|
||||||
|
"2023-6-15.jpg", // a one-digit month is too loose to trust
|
||||||
|
] {
|
||||||
|
assert_eq!(date_from_path(path), None, "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_time_that_cannot_be_is_dropped_and_the_date_kept() {
|
||||||
|
assert_eq!(
|
||||||
|
date_from_path("20230615_256199.jpg"),
|
||||||
|
at(2023, 6, 15, 0, 0, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fill_dates_only_examined_undated_rows_and_never_overrides_exif() {
|
||||||
|
let c = Connection::open_in_memory().unwrap();
|
||||||
|
crate::schema::migrate(&c).unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// (id, name, captured_at, metadata_state)
|
||||||
|
for (id, name, captured, state) in [
|
||||||
|
(1i64, "IMG_20190812_153012.jpg", None, 2i64),
|
||||||
|
// EXIF already answered; the name disagrees and loses.
|
||||||
|
(2, "IMG_20190812_153012b.jpg", Some(42i64), 2),
|
||||||
|
// Not yet examined: EXIF may still come, so the name waits.
|
||||||
|
(3, "IMG_20190813_000000.jpg", None, 1),
|
||||||
|
(4, "_MG_9002.jpg", None, 2),
|
||||||
|
] {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, ?4, 0)",
|
||||||
|
rusqlite::params![id, name, captured, state],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let captured = |id: i64| -> Option<i64> {
|
||||||
|
c.query_row("SELECT captured_at FROM images WHERE id = ?1", [id], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(fill(&c, Some(&[2, 3, 4])).unwrap(), 0);
|
||||||
|
assert_eq!(fill(&c, None).unwrap(), 1);
|
||||||
|
assert_eq!(captured(1), at(2019, 8, 12, 15, 30, 12));
|
||||||
|
assert_eq!(captured(2), Some(42));
|
||||||
|
assert_eq!(captured(3), None);
|
||||||
|
assert_eq!(captured(4), None);
|
||||||
|
// Nothing left to do is a no-op, not a rewrite.
|
||||||
|
assert_eq!(fill(&c, None).unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -356,6 +356,15 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
|||||||
out.push(("keyword_terms", n));
|
out.push(("keyword_terms", n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
// A date from the file's name for every examined image EXIF left undated.
|
||||||
|
// The sweep does this as it examines each image; this is for the images
|
||||||
|
// examined by a build that did not, and reads the undated side alone.
|
||||||
|
let n = crate::name_dates::fill(conn, None)?;
|
||||||
|
if n > 0 {
|
||||||
|
out.push(("dates_from_names", n));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,759 @@
|
|||||||
|
//! TRACES: FR-DEV-3e
|
||||||
|
//! DNG camera profiles: the tables on top of the matrix (D20).
|
||||||
|
//!
|
||||||
|
//! A profile is what [`crate::profile`] already reads — colour and forward
|
||||||
|
//! matrices per calibration illuminant — plus two lookups over HSV: the
|
||||||
|
//! `ProfileHueSatMap`, a calibration, and the `ProfileLookTable`, a rendering
|
||||||
|
//! intent. `docs/dev/camera-profiles.md` is the design; this module finds
|
||||||
|
//! them, in the order its §4 gives:
|
||||||
|
//!
|
||||||
|
//! 1. embedded in the DNG being decoded ([`Dcp::from_ifd`]);
|
||||||
|
//! 2. a `.dcp` file in the profiles directory whose `UniqueCameraModel`
|
||||||
|
//! names this body ([`find`]);
|
||||||
|
//! 3. nowhere, and the matrix renders alone.
|
||||||
|
//!
|
||||||
|
//! A `.dcp` is a TIFF whose magic is `RC` (0x4352) rather than 42, holding one
|
||||||
|
//! IFD of the same tags a DNG carries. rawler's TIFF reader does not check the
|
||||||
|
//! magic, so both sources go through the one parser and [`Dcp::from_ifd`].
|
||||||
|
//!
|
||||||
|
//! Nothing here applies a table. The lookup is the shader's, with its CPU
|
||||||
|
//! reference in `dr-pipeline`; this module resolves *which* tables, and blends
|
||||||
|
//! the HueSatMap for the light the frame was shot under, once per decode.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, OnceLock, RwLock};
|
||||||
|
|
||||||
|
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables};
|
||||||
|
use rawler::formats::tiff::{
|
||||||
|
DirectoryWriter, GenericTiffReader, SRational, TiffWriter, Value, IFD,
|
||||||
|
};
|
||||||
|
use rawler::imgop::xyz::Illuminant;
|
||||||
|
use rawler::tags::DngTag;
|
||||||
|
|
||||||
|
use crate::profile::{illuminant_temperature, Calibration, CameraProfile};
|
||||||
|
|
||||||
|
/// The magic a `.dcp` carries where a TIFF carries 42.
|
||||||
|
const DCP_MAGIC: u16 = 0x4352;
|
||||||
|
|
||||||
|
/// `ProfileEmbedPolicy` values that permit copying a profile out of the file
|
||||||
|
/// it came in: 0, "allow copying", and 3, "no restrictions". 1 ("embed if
|
||||||
|
/// used") and 2 ("embed never") do not.
|
||||||
|
const COPYABLE_POLICIES: [u32; 2] = [0, 3];
|
||||||
|
|
||||||
|
/// A camera profile as a DNG or a `.dcp` states it.
|
||||||
|
///
|
||||||
|
/// Indexed `[0]`/`[1]` for calibration 1 and 2, positionally, because that is
|
||||||
|
/// how the file pairs a matrix and a table with its illuminant.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Dcp {
|
||||||
|
/// `ProfileName`. Empty where the file names none.
|
||||||
|
pub name: String,
|
||||||
|
/// `UniqueCameraModel`: the body the profile was made for.
|
||||||
|
pub unique_camera_model: Option<String>,
|
||||||
|
pub copyright: Option<String>,
|
||||||
|
pub calibration_signature: Option<String>,
|
||||||
|
/// `ProfileEmbedPolicy`; 0 where absent, as the DNG specification
|
||||||
|
/// defaults it.
|
||||||
|
pub embed_policy: u32,
|
||||||
|
/// `CalibrationIlluminant1/2`, as EXIF light-source codes.
|
||||||
|
pub illuminants: [Option<u16>; 2],
|
||||||
|
/// `ColorMatrix1/2`: XYZ → camera.
|
||||||
|
pub color_matrix: [Option<[[f32; 3]; 3]>; 2],
|
||||||
|
/// `ForwardMatrix1/2`: white-balanced camera → XYZ (D50).
|
||||||
|
pub forward_matrix: [Option<[[f32; 3]; 3]>; 2],
|
||||||
|
/// `ProfileHueSatMapData1/2`, sharing one dimensions tag.
|
||||||
|
pub hue_sat: [Option<HueSatTable>; 2],
|
||||||
|
/// `ProfileLookTableData`.
|
||||||
|
pub look: Option<HueSatTable>,
|
||||||
|
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
|
||||||
|
/// keeps it, never applied — tone is the view transform's (D19, D20).
|
||||||
|
pub tone_curve: Option<Vec<f32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dcp {
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Read a profile out of an IFD — a DNG's root, or a `.dcp`'s only one.
|
||||||
|
///
|
||||||
|
/// `None` where the IFD carries neither table. A DNG always has matrices
|
||||||
|
/// and the decoder already reads them; what makes a *profile* worth
|
||||||
|
/// carrying separately is a table, so its absence is "no profile" rather
|
||||||
|
/// than a profile that says nothing.
|
||||||
|
pub fn from_ifd(ifd: &IFD) -> Option<Self> {
|
||||||
|
let hue_sat_dims = dims(ifd, DngTag::ProfileHueSatMapDims);
|
||||||
|
let hue_sat_srgb = encoding(ifd, DngTag::ProfileHueSatMapEncoding);
|
||||||
|
let hue_sat = [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
|
||||||
|
.map(|tag| hue_sat_dims.and_then(|d| table(ifd, tag, d, hue_sat_srgb)));
|
||||||
|
let look = dims(ifd, DngTag::ProfileLookTableDims).and_then(|d| {
|
||||||
|
table(
|
||||||
|
ifd,
|
||||||
|
DngTag::ProfileLookTableData,
|
||||||
|
d,
|
||||||
|
encoding(ifd, DngTag::ProfileLookTableEncoding),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if hue_sat[0].is_none() && hue_sat[1].is_none() && look.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
name: string(ifd, DngTag::ProfileName).unwrap_or_default(),
|
||||||
|
unique_camera_model: string(ifd, DngTag::UniqueCameraModel),
|
||||||
|
copyright: string(ifd, DngTag::ProfileCopyright),
|
||||||
|
calibration_signature: string(ifd, DngTag::ProfileCalibrationSignature),
|
||||||
|
embed_policy: ifd
|
||||||
|
.get_entry(DngTag::ProfileEmbedPolicy)
|
||||||
|
.and_then(|e| e.value.get_u32(0).ok().flatten())
|
||||||
|
.unwrap_or(0),
|
||||||
|
illuminants: [
|
||||||
|
DngTag::CalibrationIlluminant1,
|
||||||
|
DngTag::CalibrationIlluminant2,
|
||||||
|
]
|
||||||
|
.map(|tag| {
|
||||||
|
ifd.get_entry(tag)
|
||||||
|
.and_then(|e| e.value.get_u16(0).ok().flatten())
|
||||||
|
}),
|
||||||
|
color_matrix: [DngTag::ColorMatrix1, DngTag::ColorMatrix2].map(|t| matrix(ifd, t)),
|
||||||
|
forward_matrix: [DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
|
||||||
|
.map(|t| matrix(ifd, t)),
|
||||||
|
hue_sat,
|
||||||
|
look,
|
||||||
|
tone_curve: ifd
|
||||||
|
.get_entry(DngTag::ProfileToneCurve)
|
||||||
|
.and_then(|e| floats(&e.value))
|
||||||
|
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Parse a `.dcp` file's bytes.
|
||||||
|
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||||
|
if bytes.len() < 8 {
|
||||||
|
return Err("too short to be a camera profile".into());
|
||||||
|
}
|
||||||
|
let magic = match &bytes[..2] {
|
||||||
|
b"II" => u16::from_le_bytes([bytes[2], bytes[3]]),
|
||||||
|
b"MM" => u16::from_be_bytes([bytes[2], bytes[3]]),
|
||||||
|
_ => return Err("not a TIFF-structured file".into()),
|
||||||
|
};
|
||||||
|
if magic != DCP_MAGIC {
|
||||||
|
return Err(format!("magic {magic:#x} is not a camera profile's"));
|
||||||
|
}
|
||||||
|
let reader =
|
||||||
|
GenericTiffReader::new_with_buffer(bytes, 0, 0, Some(0)).map_err(|e| e.to_string())?;
|
||||||
|
use rawler::formats::tiff::reader::TiffReader;
|
||||||
|
let profile = Self::from_ifd(reader.root_ifd())
|
||||||
|
.ok_or_else(|| "a profile with no HueSatMap and no LookTable".to_string())?;
|
||||||
|
if profile.color_matrix[0].is_none() {
|
||||||
|
return Err("a profile with no ColorMatrix1".into());
|
||||||
|
}
|
||||||
|
Ok(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the file this profile came in allows it to be copied out
|
||||||
|
/// (camera-profiles.md §4).
|
||||||
|
pub fn may_copy(&self) -> bool {
|
||||||
|
COPYABLE_POLICIES.contains(&self.embed_policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Whether this profile was made for the body named.
|
||||||
|
///
|
||||||
|
/// `unique` is the file's own `UniqueCameraModel`, where a DNG carries
|
||||||
|
/// one; `make` and `model` are rawler's cleaned names, joined as Adobe
|
||||||
|
/// spells a body ("Canon EOS 6D"). Case and runs of spaces are ignored,
|
||||||
|
/// because the two spellings come from different vendors' tables.
|
||||||
|
pub fn is_for(&self, unique: Option<&str>, make: &str, model: &str) -> bool {
|
||||||
|
let Some(mine) = self.unique_camera_model.as_deref().map(normalise) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let joined = if normalise(model).starts_with(&normalise(make)) {
|
||||||
|
normalise(model)
|
||||||
|
} else {
|
||||||
|
normalise(&format!("{make} {model}"))
|
||||||
|
};
|
||||||
|
unique.map(normalise).as_deref() == Some(mine.as_str()) || joined == mine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The matrices this profile was built against, as the decoder's
|
||||||
|
/// [`CameraProfile`], with the frame's own as-shot neutral.
|
||||||
|
///
|
||||||
|
/// A `.dcp` is a whole profile: its tables were measured relative to its
|
||||||
|
/// forward matrix, so using them over the file's matrices would apply a
|
||||||
|
/// correction for a different starting point. `None` where no calibration
|
||||||
|
/// is usable, and the caller keeps the file's.
|
||||||
|
pub fn camera_profile(&self, neutral: Option<[f32; 3]>) -> Option<CameraProfile> {
|
||||||
|
let calibrations = (0..2)
|
||||||
|
.filter_map(|i| {
|
||||||
|
let xyz_to_cam = self.color_matrix[i]?;
|
||||||
|
let temperature = self.temperature(i)?;
|
||||||
|
Some(Calibration {
|
||||||
|
temperature,
|
||||||
|
xyz_to_cam,
|
||||||
|
forward: self.forward_matrix[i],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
CameraProfile::new(calibrations, neutral)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The tables to render this frame with: the HueSatMap blended for the
|
||||||
|
/// scene's colour temperature, by the same mired weight the matrices use,
|
||||||
|
/// and the LookTable as it is.
|
||||||
|
///
|
||||||
|
/// Tables that change nothing are dropped here, so the shader is never
|
||||||
|
/// asked to look up an identity.
|
||||||
|
pub fn tables(&self, scene_temperature: f32, origin: ProfileOrigin) -> ProfileTables {
|
||||||
|
let hue_sat = match (&self.hue_sat, self.temperature(0), self.temperature(1)) {
|
||||||
|
([Some(a), Some(b)], Some(ta), Some(tb)) => {
|
||||||
|
let t = mired_weight(ta, tb, scene_temperature);
|
||||||
|
a.lerp(b, t).or_else(|| Some(a.clone()))
|
||||||
|
}
|
||||||
|
([Some(a), _], _, _) => Some(a.clone()),
|
||||||
|
([None, Some(b)], _, _) => Some(b.clone()),
|
||||||
|
([None, None], _, _) => None,
|
||||||
|
};
|
||||||
|
ProfileTables {
|
||||||
|
name: self.name.clone(),
|
||||||
|
origin,
|
||||||
|
hue_sat: hue_sat.filter(|t| !t.is_identity()),
|
||||||
|
look: self.look.clone().filter(|t| !t.is_identity()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temperature(&self, i: usize) -> Option<f32> {
|
||||||
|
let code = self.illuminants[i]?;
|
||||||
|
let illuminant: Illuminant = code.try_into().ok()?;
|
||||||
|
illuminant_temperature(illuminant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// This profile as `.dcp` bytes, for [`save`].
|
||||||
|
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
let writer = TiffWriter::new(&mut cursor).map_err(|e| e.to_string())?;
|
||||||
|
let mut dir = DirectoryWriter::new();
|
||||||
|
if let Some(model) = &self.unique_camera_model {
|
||||||
|
dir.add_tag(DngTag::UniqueCameraModel, model.as_str());
|
||||||
|
}
|
||||||
|
dir.add_tag(DngTag::ProfileName, self.name.as_str());
|
||||||
|
if let Some(c) = &self.copyright {
|
||||||
|
dir.add_tag(DngTag::ProfileCopyright, c.as_str());
|
||||||
|
}
|
||||||
|
if let Some(s) = &self.calibration_signature {
|
||||||
|
dir.add_tag(DngTag::ProfileCalibrationSignature, s.as_str());
|
||||||
|
}
|
||||||
|
dir.add_tag(DngTag::ProfileEmbedPolicy, self.embed_policy);
|
||||||
|
let illuminant_tags = [
|
||||||
|
DngTag::CalibrationIlluminant1,
|
||||||
|
DngTag::CalibrationIlluminant2,
|
||||||
|
];
|
||||||
|
for (tag, code) in illuminant_tags.into_iter().zip(self.illuminants) {
|
||||||
|
if let Some(code) = code {
|
||||||
|
dir.add_tag(tag, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (tag, m) in [DngTag::ColorMatrix1, DngTag::ColorMatrix2]
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.color_matrix)
|
||||||
|
.chain(
|
||||||
|
[DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.forward_matrix),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if let Some(m) = m {
|
||||||
|
dir.add_value(tag, srational_matrix(&m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(first) = self.hue_sat.iter().flatten().next() {
|
||||||
|
dir.add_tag(
|
||||||
|
DngTag::ProfileHueSatMapDims,
|
||||||
|
[
|
||||||
|
first.hue_divisions,
|
||||||
|
first.sat_divisions,
|
||||||
|
first.val_divisions,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
dir.add_tag(
|
||||||
|
DngTag::ProfileHueSatMapEncoding,
|
||||||
|
u32::from(first.srgb_encoded),
|
||||||
|
);
|
||||||
|
for (tag, t) in [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
|
||||||
|
.into_iter()
|
||||||
|
.zip(&self.hue_sat)
|
||||||
|
{
|
||||||
|
if let Some(t) = t {
|
||||||
|
dir.add_value(
|
||||||
|
tag,
|
||||||
|
Value::Float(t.entries.iter().flatten().copied().collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(t) = &self.look {
|
||||||
|
dir.add_tag(
|
||||||
|
DngTag::ProfileLookTableDims,
|
||||||
|
[t.hue_divisions, t.sat_divisions, t.val_divisions],
|
||||||
|
);
|
||||||
|
dir.add_tag(DngTag::ProfileLookTableEncoding, u32::from(t.srgb_encoded));
|
||||||
|
dir.add_value(
|
||||||
|
DngTag::ProfileLookTableData,
|
||||||
|
Value::Float(t.entries.iter().flatten().copied().collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(curve) = &self.tone_curve {
|
||||||
|
dir.add_value(DngTag::ProfileToneCurve, Value::Float(curve.clone()));
|
||||||
|
}
|
||||||
|
writer.build(dir).map_err(|e| e.to_string())?;
|
||||||
|
let mut bytes = cursor.into_inner();
|
||||||
|
// The writer stamps TIFF's 42 in its own byte order; a profile is the
|
||||||
|
// same structure with its own magic in the same place.
|
||||||
|
bytes[2..4].copy_from_slice(&DCP_MAGIC.to_ne_bytes());
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The weight toward calibration 2, by reciprocal temperature — the same
|
||||||
|
/// interpolation [`CameraProfile`] gives the matrices, so the tables and the
|
||||||
|
/// matrix agree about how far between the two lights a frame was shot.
|
||||||
|
fn mired_weight(t1: f32, t2: f32, scene: f32) -> f32 {
|
||||||
|
let mired = |k: f32| 1.0e6 / k.max(1.0);
|
||||||
|
let (a, b) = (mired(t1), mired(t2));
|
||||||
|
if (a - b).abs() < 1e-6 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
((mired(scene) - a) / (b - a)).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalise(s: &str) -> String {
|
||||||
|
s.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
|
||||||
|
ifd.get_entry(tag)
|
||||||
|
.and_then(|e| e.value.as_string().cloned())
|
||||||
|
.map(|s| s.trim_end_matches('\0').trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn floats(value: &Value) -> Option<Vec<f32>> {
|
||||||
|
(0..value.count())
|
||||||
|
.map(|i| value.get_f32(i).ok().flatten())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matrix(ifd: &IFD, tag: DngTag) -> Option<[[f32; 3]; 3]> {
|
||||||
|
let v = floats(&ifd.get_entry(tag)?.value)?;
|
||||||
|
if v.len() != 9 || v.iter().any(|x| !x.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dims(ifd: &IFD, tag: DngTag) -> Option<[u32; 3]> {
|
||||||
|
let e = ifd.get_entry(tag)?;
|
||||||
|
let at = |i| e.value.get_u32(i).ok().flatten();
|
||||||
|
Some([at(0)?, at(1)?, at(2)?])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoding(ifd: &IFD, tag: DngTag) -> bool {
|
||||||
|
ifd.get_entry(tag)
|
||||||
|
.and_then(|e| e.value.get_u32(0).ok().flatten())
|
||||||
|
== Some(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table(ifd: &IFD, tag: DngTag, [h, s, v]: [u32; 3], srgb: bool) -> Option<HueSatTable> {
|
||||||
|
let data = floats(&ifd.get_entry(tag)?.value)?;
|
||||||
|
if data.len() % 3 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let entries = data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||||
|
HueSatTable::new(h, s, v, srgb, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn srational_matrix(m: &[[f32; 3]; 3]) -> Value {
|
||||||
|
const SCALE: i32 = 10_000;
|
||||||
|
Value::SRational(
|
||||||
|
m.iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|v| SRational::new((v * SCALE as f32).round() as i32, SCALE))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the profiles directory -------------------------------------------------
|
||||||
|
|
||||||
|
/// The `.dcp` files the photographer has installed, loaded once per process.
|
||||||
|
struct Library {
|
||||||
|
dir: PathBuf,
|
||||||
|
/// `(file name, profile)`, sorted by file name so that two profiles for
|
||||||
|
/// one body resolve the same way on every run (camera-profiles.md §4).
|
||||||
|
profiles: Vec<(String, Arc<Dcp>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library() -> &'static RwLock<Option<Library>> {
|
||||||
|
static LIBRARY: OnceLock<RwLock<Option<Library>>> = OnceLock::new();
|
||||||
|
LIBRARY.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Name the profiles directory and read every `.dcp` in it.
|
||||||
|
///
|
||||||
|
/// Called once at start-up by the application, with a path under the
|
||||||
|
/// platform data directory. A decode before this, or in a process that never
|
||||||
|
/// calls it (a test, a bench), finds no directory profiles, which is the
|
||||||
|
/// matrix-only render it always had.
|
||||||
|
pub fn set_profiles_directory(dir: PathBuf) {
|
||||||
|
let profiles = load(&dir);
|
||||||
|
if let Ok(mut lib) = library().write() {
|
||||||
|
*lib = Some(Library { dir, profiles });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory [`set_profiles_directory`] named, if any.
|
||||||
|
pub fn profiles_directory() -> Option<PathBuf> {
|
||||||
|
library().read().ok()?.as_ref().map(|l| l.dir.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(dir: &Path) -> Vec<(String, Arc<Dcp>)> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<(String, Arc<Dcp>)> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| {
|
||||||
|
e.path()
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|x| x.eq_ignore_ascii_case("dcp"))
|
||||||
|
})
|
||||||
|
.filter_map(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
|
let bytes = std::fs::read(e.path()).ok()?;
|
||||||
|
match Dcp::parse(&bytes) {
|
||||||
|
Ok(p) => Some((name, Arc::new(p))),
|
||||||
|
Err(why) => {
|
||||||
|
log::warn!("camera profile {name} skipped: {why}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
log::info!(
|
||||||
|
"camera profiles: {} loaded from {}",
|
||||||
|
out.len(),
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The first installed profile, by file name, made for this body.
|
||||||
|
pub fn find(unique: Option<&str>, make: &str, model: &str) -> Option<(String, Arc<Dcp>)> {
|
||||||
|
let lib = library().read().ok()?;
|
||||||
|
lib.as_ref()?
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|(_, p)| p.is_for(unique, make, model))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Save a profile copied out of a photograph into the profiles directory, and
|
||||||
|
/// make it available to the next decode.
|
||||||
|
///
|
||||||
|
/// Refuses a profile whose embed policy does not allow copying, and refuses
|
||||||
|
/// when no directory is set. Named after the body and the profile, so a
|
||||||
|
/// second copy of the same profile replaces the first rather than piling up.
|
||||||
|
pub fn save(profile: &Dcp) -> Result<PathBuf, String> {
|
||||||
|
if !profile.may_copy() {
|
||||||
|
return Err("this profile's embed policy does not allow copying it".into());
|
||||||
|
}
|
||||||
|
let model = profile
|
||||||
|
.unique_camera_model
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("the profile names no camera")?;
|
||||||
|
let dir = profiles_directory().ok_or("no profiles directory is set")?;
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let file_name: String = format!("{model} {}.dcp", profile.name)
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || " -_.".contains(c) {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let path = dir.join(file_name.trim());
|
||||||
|
std::fs::write(&path, profile.to_bytes()?).map_err(|e| e.to_string())?;
|
||||||
|
set_profiles_directory(dir);
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The profile embedded in a file, read on demand — for the panel's offer to
|
||||||
|
/// copy it, which happens long after the decode that rendered it.
|
||||||
|
///
|
||||||
|
/// Reads the header only; no photosite is unpacked.
|
||||||
|
pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
|
||||||
|
let source = rawler::rawsource::RawSource::new_from_slice(bytes);
|
||||||
|
let decoder = rawler::get_decoder(&source).ok()?;
|
||||||
|
let root = decoder
|
||||||
|
.ifd(rawler::decoders::WellKnownIFD::Root)
|
||||||
|
.ok()
|
||||||
|
.flatten()?;
|
||||||
|
Dcp::from_ifd(&root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// What one decode resolved: the matrices to render through, the tables on
|
||||||
|
/// top of them, and the embedded profile if the file had one — kept whole so
|
||||||
|
/// the panel can offer to copy it.
|
||||||
|
pub struct Resolved {
|
||||||
|
pub profile: Option<CameraProfile>,
|
||||||
|
pub tables: Option<Arc<ProfileTables>>,
|
||||||
|
pub embedded: Option<Arc<Dcp>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Apply camera-profiles.md §4's order to one decoded file.
|
||||||
|
///
|
||||||
|
/// `matrices` is the profile the decoder built from the file; `root` the
|
||||||
|
/// file's root IFD, where a DNG keeps its embedded profile.
|
||||||
|
pub fn resolve(
|
||||||
|
matrices: Option<CameraProfile>,
|
||||||
|
root: Option<&IFD>,
|
||||||
|
make: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Resolved {
|
||||||
|
let embedded = root.and_then(Dcp::from_ifd).map(Arc::new);
|
||||||
|
if let Some(dcp) = &embedded {
|
||||||
|
let tables = matrices
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| dcp.tables(m.scene_temperature(), ProfileOrigin::Embedded))
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.map(Arc::new);
|
||||||
|
return Resolved {
|
||||||
|
profile: matrices,
|
||||||
|
tables,
|
||||||
|
embedded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let unique = root.and_then(|r| string(r, DngTag::UniqueCameraModel));
|
||||||
|
if let Some((file, dcp)) = find(unique.as_deref(), make, model) {
|
||||||
|
let neutral = matrices.as_ref().and_then(|m| m.neutral());
|
||||||
|
if let Some(own) = dcp.camera_profile(neutral) {
|
||||||
|
let tables = dcp.tables(own.scene_temperature(), ProfileOrigin::File(file));
|
||||||
|
return Resolved {
|
||||||
|
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
|
||||||
|
profile: Some(own),
|
||||||
|
embedded: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Resolved {
|
||||||
|
profile: matrices,
|
||||||
|
tables: None,
|
||||||
|
embedded: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn table(h: u32, s: u32, v: u32, fill: [f32; 3]) -> HueSatTable {
|
||||||
|
HueSatTable::new(h, s, v, false, vec![fill; (h * s * v) as usize]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample() -> Dcp {
|
||||||
|
Dcp {
|
||||||
|
name: "Test Standard".into(),
|
||||||
|
unique_camera_model: Some("Canon EOS 6D".into()),
|
||||||
|
copyright: Some("nobody".into()),
|
||||||
|
calibration_signature: Some("com.example".into()),
|
||||||
|
embed_policy: 0,
|
||||||
|
illuminants: [Some(17), Some(21)],
|
||||||
|
color_matrix: [
|
||||||
|
Some([
|
||||||
|
[0.7546, -0.1435, -0.0929],
|
||||||
|
[-0.3846, 1.1488, 0.2692],
|
||||||
|
[-0.0332, 0.1209, 0.637],
|
||||||
|
]),
|
||||||
|
Some([
|
||||||
|
[0.7034, -0.0804, -0.1014],
|
||||||
|
[-0.442, 1.2564, 0.2058],
|
||||||
|
[-0.0851, 0.1994, 0.5758],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
forward_matrix: [
|
||||||
|
Some([
|
||||||
|
[0.7763, 0.0065, 0.1815],
|
||||||
|
[0.2364, 0.8351, -0.0715],
|
||||||
|
[-0.0059, -0.4228, 1.2538],
|
||||||
|
]),
|
||||||
|
Some([
|
||||||
|
[0.7464, 0.1044, 0.1135],
|
||||||
|
[0.2648, 0.9173, -0.182],
|
||||||
|
[0.0113, -0.2154, 1.0292],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
hue_sat: [
|
||||||
|
Some(table(6, 3, 1, [2.0, 1.1, 1.0])),
|
||||||
|
Some(table(6, 3, 1, [-2.0, 0.9, 1.0])),
|
||||||
|
],
|
||||||
|
look: Some(table(4, 2, 3, [0.0, 1.2, 0.95])),
|
||||||
|
tone_curve: Some(vec![0.0, 0.0, 0.5, 0.6, 1.0, 1.0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_survives_being_written_and_read_back() {
|
||||||
|
let original = sample();
|
||||||
|
let bytes = original.to_bytes().unwrap();
|
||||||
|
assert_eq!(&bytes[2..4], &DCP_MAGIC.to_ne_bytes());
|
||||||
|
let back = Dcp::parse(&bytes).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
back.hue_sat, original.hue_sat,
|
||||||
|
"tables are stored as f32 and come back exact"
|
||||||
|
);
|
||||||
|
assert_eq!(back.look, original.look);
|
||||||
|
assert_eq!(back.name, original.name);
|
||||||
|
assert_eq!(back.unique_camera_model, original.unique_camera_model);
|
||||||
|
assert_eq!(back.illuminants, original.illuminants);
|
||||||
|
assert_eq!(back.tone_curve, original.tone_curve);
|
||||||
|
assert_eq!(
|
||||||
|
back.forward_matrix, original.forward_matrix,
|
||||||
|
"four decimals, as the file has"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tiff_is_not_a_profile() {
|
||||||
|
let mut bytes = sample().to_bytes().unwrap();
|
||||||
|
bytes[2..4].copy_from_slice(&42u16.to_ne_bytes());
|
||||||
|
assert!(Dcp::parse(&bytes).is_err());
|
||||||
|
assert!(Dcp::parse(b"nonsense").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_matches_by_unique_model_or_by_make_and_model() {
|
||||||
|
let p = sample();
|
||||||
|
assert!(p.is_for(None, "Canon", "EOS 6D"));
|
||||||
|
assert!(p.is_for(None, "canon", "eos 6d"));
|
||||||
|
assert!(p.is_for(Some("Canon EOS 6D"), "", ""));
|
||||||
|
assert!(!p.is_for(None, "Canon", "EOS 6D Mark II"));
|
||||||
|
assert!(!p.is_for(Some("Canon EOS 5D"), "Canon", "EOS 5D"));
|
||||||
|
// A model that already starts with the make is not doubled.
|
||||||
|
assert!(p.is_for(None, "Canon", "Canon EOS 6D"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hue_sat_map_follows_the_light_the_frame_was_shot_under() {
|
||||||
|
let p = sample();
|
||||||
|
let at = |k| {
|
||||||
|
p.tables(k, ProfileOrigin::Embedded)
|
||||||
|
.hue_sat
|
||||||
|
.unwrap()
|
||||||
|
.entries[0]
|
||||||
|
};
|
||||||
|
assert_eq!(at(2856.0), [2.0, 1.1, 1.0], "tungsten is calibration 1");
|
||||||
|
assert_eq!(at(6504.0), [-2.0, 0.9, 1.0], "daylight is calibration 2");
|
||||||
|
let mid = at(4000.0);
|
||||||
|
assert!(mid[0] > -2.0 && mid[0] < 2.0, "{mid:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_that_changes_nothing_is_not_handed_on() {
|
||||||
|
let mut p = sample();
|
||||||
|
p.hue_sat = [Some(table(6, 3, 1, [0.0, 1.0, 1.0])), None];
|
||||||
|
let t = p.tables(5000.0, ProfileOrigin::Embedded);
|
||||||
|
assert!(t.hue_sat.is_none());
|
||||||
|
assert!(t.look.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_copyable_policy_may_be_copied() {
|
||||||
|
let mut p = sample();
|
||||||
|
for (policy, ok) in [(0, true), (1, false), (2, false), (3, true)] {
|
||||||
|
p.embed_policy = policy;
|
||||||
|
assert_eq!(p.may_copy(), ok, "policy {policy}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Canon 6D DNG from the library, written by Lightroom 6.14 with Adobe
|
||||||
|
/// Standard embedded. Read from `DR_DCP_SAMPLE`, else the library path the
|
||||||
|
/// figures in camera-profiles.md §1 came from; skipped where neither
|
||||||
|
/// exists, because the file is not ours to put in the repository.
|
||||||
|
fn six_d_dng() -> Option<Vec<u8>> {
|
||||||
|
let path = std::env::var_os("DR_DCP_SAMPLE")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var_os("HOME").map(|h| {
|
||||||
|
PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
let bytes = std::fs::read(&path).ok();
|
||||||
|
if bytes.is_none() {
|
||||||
|
eprintln!("skipped: no sample DNG at {}", path.display());
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_libraries_six_d_dngs_carry_adobe_standard() {
|
||||||
|
let Some(bytes) = six_d_dng() else { return };
|
||||||
|
let p = embedded_in(&bytes).expect("an embedded profile");
|
||||||
|
assert_eq!(p.name, "Adobe Standard");
|
||||||
|
assert_eq!(p.unique_camera_model.as_deref(), Some("Canon EOS 6D"));
|
||||||
|
assert_eq!(p.embed_policy, 0);
|
||||||
|
let hs = p.hue_sat[0].as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(hs.hue_divisions, hs.sat_divisions, hs.val_divisions),
|
||||||
|
(90, 30, 1)
|
||||||
|
);
|
||||||
|
assert!(p.hue_sat[1].is_some());
|
||||||
|
let look = p.look.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(look.hue_divisions, look.sat_divisions, look.val_divisions),
|
||||||
|
(36, 8, 16)
|
||||||
|
);
|
||||||
|
assert!(p.tone_curve.is_none());
|
||||||
|
assert!(p.may_copy());
|
||||||
|
|
||||||
|
let back = Dcp::parse(&p.to_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
back.hue_sat, p.hue_sat,
|
||||||
|
"a copied profile keeps its tables bit for bit"
|
||||||
|
);
|
||||||
|
assert_eq!(back.look, p.look);
|
||||||
|
assert!(
|
||||||
|
back.is_for(None, "Canon", "EOS 6D"),
|
||||||
|
"and so applies to the body's CR2s"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoding_the_six_d_dng_hands_on_its_tables() {
|
||||||
|
let Some(bytes) = six_d_dng() else { return };
|
||||||
|
let raw = crate::decode(&bytes).unwrap();
|
||||||
|
let tables = raw.profile_tables.expect("tables");
|
||||||
|
assert_eq!(tables.origin, ProfileOrigin::Embedded);
|
||||||
|
assert_eq!(tables.name, "Adobe Standard");
|
||||||
|
assert!(tables.hue_sat.is_some() && tables.look.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_brings_its_own_matrices() {
|
||||||
|
let p = sample();
|
||||||
|
let cam = p.camera_profile(Some([0.5, 1.0, 0.7])).unwrap();
|
||||||
|
assert_eq!(cam.calibrations().len(), 2);
|
||||||
|
assert!(cam.calibrations().iter().all(|c| c.forward.is_some()));
|
||||||
|
assert!(cam.cam_to_srgb().is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
//! second decoder can be put behind them without changing any of them
|
//! second decoder can be put behind them without changing any of them
|
||||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||||
|
|
||||||
|
pub mod dcp;
|
||||||
mod decoder;
|
mod decoder;
|
||||||
mod error;
|
mod error;
|
||||||
mod locate;
|
mod locate;
|
||||||
@@ -137,8 +138,17 @@ pub struct RawImage {
|
|||||||
/// carry on: calibrations and the as-shot neutral. `None` for a body the
|
/// carry on: calibrations and the as-shot neutral. `None` for a body the
|
||||||
/// decoder has no matrix for.
|
/// decoder has no matrix for.
|
||||||
pub profile: Option<profile::CameraProfile>,
|
pub profile: Option<profile::CameraProfile>,
|
||||||
/// The body, as rawler cleans the names: what `Make`/`Model` say and what
|
/// TRACES: FR-DEV-3e
|
||||||
/// the base-curve database matches on.
|
/// The camera profile's HueSatMap and LookTable, resolved for this frame
|
||||||
|
/// (D20): embedded in the DNG, or from a matched `.dcp`. `None` renders
|
||||||
|
/// through the matrix alone.
|
||||||
|
///
|
||||||
|
/// Carried with the image, as `color_matrix` is, so that every path that
|
||||||
|
/// renders a decoded file renders it through the same profile without
|
||||||
|
/// having to be told — see camera-profiles.md §3.
|
||||||
|
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
|
||||||
|
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
|
||||||
|
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
|
||||||
pub make: String,
|
pub make: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
@@ -571,6 +581,23 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
// is now structural, because there is only one interpolated matrix and
|
// is now structural, because there is only one interpolated matrix and
|
||||||
// both callers ask the same object for it.
|
// both callers ask the same object for it.
|
||||||
let profile = profile::CameraProfile::extract(&image, &dng);
|
let profile = profile::CameraProfile::extract(&image, &dng);
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
// The tables, and — where a `.dcp` supplies them — the matrices they were
|
||||||
|
// built against, which then stand in for the file's (D20).
|
||||||
|
let root = decoder
|
||||||
|
.ifd(rawler::decoders::WellKnownIFD::Root)
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let dcp::Resolved {
|
||||||
|
profile,
|
||||||
|
tables: profile_tables,
|
||||||
|
..
|
||||||
|
} = dcp::resolve(
|
||||||
|
profile,
|
||||||
|
root.as_deref(),
|
||||||
|
&image.camera.clean_make,
|
||||||
|
&image.camera.clean_model,
|
||||||
|
);
|
||||||
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
|
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
|
||||||
let wb_coeffs = sane_wb(
|
let wb_coeffs = sane_wb(
|
||||||
image.wb_coeffs,
|
image.wb_coeffs,
|
||||||
@@ -661,6 +688,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
color_matrix,
|
color_matrix,
|
||||||
samples_per_pixel,
|
samples_per_pixel,
|
||||||
profile,
|
profile,
|
||||||
|
profile_tables,
|
||||||
make: image.camera.clean_make.clone(),
|
make: image.camera.clean_make.clone(),
|
||||||
model: image.camera.clean_model.clone(),
|
model: image.camera.clean_model.clone(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ fn cct_from_xy(x: f32, y: f32) -> f32 {
|
|||||||
/// but a profile calibrated under fluorescent light is describing a sensor
|
/// but a profile calibrated under fluorescent light is describing a sensor
|
||||||
/// under fluorescent light, and placing it at roughly the right colour is much
|
/// under fluorescent light, and placing it at roughly the right colour is much
|
||||||
/// better than discarding it.
|
/// better than discarding it.
|
||||||
fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
pub(crate) fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
||||||
Some(match illuminant {
|
Some(match illuminant {
|
||||||
// CIE standard illuminant A: a tungsten filament at 2856 K. The low
|
// CIE standard illuminant A: a tungsten filament at 2856 K. The low
|
||||||
// end of essentially every dual-illuminant profile ever written.
|
// end of essentially every dual-illuminant profile ever written.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ fn main() {
|
|||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
let Some(input) = args.next() else {
|
let Some(input) = args.next() else {
|
||||||
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
|
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
|
||||||
eprintln!(" preset: neutral (default) | punchy | recover");
|
eprintln!(" preset: neutral (default) | matrix | look200 | punchy | recover | …");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
};
|
};
|
||||||
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
|
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
|
||||||
@@ -78,6 +78,24 @@ fn main() {
|
|||||||
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
|
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
|
||||||
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
|
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
|
||||||
}
|
}
|
||||||
|
// The camera profile switched off: the matrix alone, as every
|
||||||
|
// photograph rendered before D20. Beside "neutral" on a DNG that
|
||||||
|
// embeds a profile, the difference is the profile's tables.
|
||||||
|
"matrix" => {
|
||||||
|
graph.set_param(
|
||||||
|
dr_pipeline::ops::camera_profile::ID,
|
||||||
|
dr_pipeline::ops::camera_profile::APPLY,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The profile's look table at twice its strength.
|
||||||
|
"look200" => {
|
||||||
|
graph.set_param(
|
||||||
|
dr_pipeline::ops::camera_profile::ID,
|
||||||
|
dr_pipeline::ops::camera_profile::LOOK,
|
||||||
|
200.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Contrast alone, so its effect can be judged without anything else
|
// Contrast alone, so its effect can be judged without anything else
|
||||||
// moving.
|
// moving.
|
||||||
"contrast" => {
|
"contrast" => {
|
||||||
@@ -109,6 +127,14 @@ fn main() {
|
|||||||
graph.set_param(curve::ID, curve::P0_Y, 0.12);
|
graph.set_param(curve::ID, curve::P0_Y, 0.12);
|
||||||
graph.set_param(curve::ID, curve::P1_Y, 0.32);
|
graph.set_param(curve::ID, curve::P1_Y, 0.32);
|
||||||
}
|
}
|
||||||
|
// A shipped preset by name — `preset:Vivid landscape` — applied as
|
||||||
|
// the presets menu applies it, so a look can be judged on a real file.
|
||||||
|
named if named.starts_with("preset:") => {
|
||||||
|
let name = &named["preset:".len()..];
|
||||||
|
let preset = dr_pipeline::bundled::lookup(&Default::default(), name)
|
||||||
|
.unwrap_or_else(|| panic!("no shipped preset called {name:?}"));
|
||||||
|
let _ = preset.apply(&mut graph, dr_pipeline::Scope::adjustments());
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,8 +149,15 @@ fn main() {
|
|||||||
let mut adjust = AdjustPass::new(&ctx);
|
let mut adjust = AdjustPass::new(&ctx);
|
||||||
let (w, h) = image.size();
|
let (w, h) = image.size();
|
||||||
|
|
||||||
|
// Through the detail stage when the edit has one — clarity, sharpening
|
||||||
|
// — which is the path every frontend takes; `render` alone refuses such
|
||||||
|
// a shader.
|
||||||
let t2 = std::time::Instant::now();
|
let t2 = std::time::Instant::now();
|
||||||
adjust.render(&image, &shader, w, h).expect("adjust");
|
let detail = graph.compose_detail(image.size(), (w, h));
|
||||||
|
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||||
|
adjust
|
||||||
|
.render_detailed(&image, &shader, w, h, None, &detail, key)
|
||||||
|
.expect("adjust");
|
||||||
ctx.device
|
ctx.device
|
||||||
.poll(wgpu::PollType::wait_indefinitely())
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
.expect("poll");
|
.expect("poll");
|
||||||
@@ -134,8 +167,11 @@ fn main() {
|
|||||||
// path, and it must not recompile.
|
// path, and it must not recompile.
|
||||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
|
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
|
||||||
let again = graph.compose();
|
let again = graph.compose();
|
||||||
|
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||||
let t3 = std::time::Instant::now();
|
let t3 = std::time::Instant::now();
|
||||||
adjust.render(&image, &again, w, h).expect("adjust");
|
adjust
|
||||||
|
.render_detailed(&image, &again, w, h, None, &detail, key)
|
||||||
|
.expect("adjust");
|
||||||
ctx.device
|
ctx.device
|
||||||
.poll(wgpu::PollType::wait_indefinitely())
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
.expect("poll");
|
.expect("poll");
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ pub struct AdjustPass {
|
|||||||
empty_film_lut: wgpu::TextureView,
|
empty_film_lut: wgpu::TextureView,
|
||||||
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
|
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
|
||||||
film: Option<FilmTextures>,
|
film: Option<FilmTextures>,
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Bound at `@binding(8)` for a source with no camera profile tables: the
|
||||||
|
/// two-entry header of zeros that tells the fragment there is nothing to
|
||||||
|
/// apply (D20).
|
||||||
|
empty_profile: wgpu::Buffer,
|
||||||
|
/// The current source's tables, uploaded, keyed by
|
||||||
|
/// [`DemosaicedImage::id`] — one upload per source rather than per frame.
|
||||||
|
profile: Option<(u64, wgpu::Buffer)>,
|
||||||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||||||
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
|
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
|
||||||
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader
|
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader
|
||||||
@@ -512,6 +520,37 @@ impl AdjustPass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The curve texture to bind: the loaded stock's, or the placeholder.
|
/// The curve texture to bind: the loaded stock's, or the placeholder.
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// A source's camera profile tables as the storage buffer
|
||||||
|
/// `@binding(8)` reads, laid out by `dr_pipeline`'s `profile_buffer`.
|
||||||
|
fn upload_profile(ctx: &GpuContext, tables: Option<&dr_types::ProfileTables>) -> wgpu::Buffer {
|
||||||
|
let data = dr_pipeline::ops::camera_profile::profile_buffer(tables);
|
||||||
|
ctx.device
|
||||||
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("adjust-profile-tables"),
|
||||||
|
contents: bytemuck::cast_slice(&data),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The buffer to bind for `source`: its tables, uploaded once per source,
|
||||||
|
/// or the empty header. A cheap handle, cloned out so a caller holding
|
||||||
|
/// other borrows of `self` can bind it.
|
||||||
|
fn profile_buffer(&mut self, source: &DemosaicedImage) -> wgpu::Buffer {
|
||||||
|
let Some(tables) = source.profile_tables() else {
|
||||||
|
return self.empty_profile.clone();
|
||||||
|
};
|
||||||
|
if let Some((id, buffer)) = &self.profile {
|
||||||
|
if *id == source.id() {
|
||||||
|
return buffer.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let buffer = Self::upload_profile(&self.ctx, Some(tables));
|
||||||
|
self.profile = Some((source.id(), buffer.clone()));
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
fn film_curves_view(&self) -> &wgpu::TextureView {
|
fn film_curves_view(&self) -> &wgpu::TextureView {
|
||||||
self.film
|
self.film
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -645,6 +684,8 @@ impl AdjustPass {
|
|||||||
empty_film_curves,
|
empty_film_curves,
|
||||||
empty_film_lut,
|
empty_film_lut,
|
||||||
film: None,
|
film: None,
|
||||||
|
empty_profile: Self::upload_profile(ctx, None),
|
||||||
|
profile: None,
|
||||||
detail: DetailRunner::new(ctx),
|
detail: DetailRunner::new(ctx),
|
||||||
linear_bind_group_layout,
|
linear_bind_group_layout,
|
||||||
linear_pipeline_layout,
|
linear_pipeline_layout,
|
||||||
@@ -775,6 +816,17 @@ impl AdjustPass {
|
|||||||
},
|
},
|
||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
|
// The camera profile's tables (FR-DEV-3e, D20).
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 8,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -973,6 +1025,7 @@ impl AdjustPass {
|
|||||||
usage: wgpu::BufferUsages::UNIFORM,
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let profile = self.profile_buffer(source);
|
||||||
let pipeline = self
|
let pipeline = self
|
||||||
.cache
|
.cache
|
||||||
.get(&shader.structure_hash)
|
.get(&shader.structure_hash)
|
||||||
@@ -1020,6 +1073,10 @@ impl AdjustPass {
|
|||||||
binding: 7,
|
binding: 7,
|
||||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||||
},
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 8,
|
||||||
|
resource: profile.as_entire_binding(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1139,6 +1196,7 @@ impl AdjustPass {
|
|||||||
// be read off `self` at the point the bind group is built.
|
// be read off `self` at the point the bind group is built.
|
||||||
let film_curves = self.film_curves_view().clone();
|
let film_curves = self.film_curves_view().clone();
|
||||||
let film_lut = self.film_lut_view().clone();
|
let film_lut = self.film_lut_view().clone();
|
||||||
|
let profile = self.profile_buffer(source);
|
||||||
|
|
||||||
let mut enc = self
|
let mut enc = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -1202,6 +1260,10 @@ impl AdjustPass {
|
|||||||
binding: 7,
|
binding: 7,
|
||||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||||
},
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 8,
|
||||||
|
resource: profile.as_entire_binding(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let pipeline = self
|
let pipeline = self
|
||||||
@@ -1298,6 +1360,10 @@ impl AdjustPass {
|
|||||||
binding: 7,
|
binding: 7,
|
||||||
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||||
},
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 8,
|
||||||
|
resource: profile.as_entire_binding(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
{
|
{
|
||||||
@@ -1609,6 +1675,10 @@ impl AdjustPass {
|
|||||||
binding: 7,
|
binding: 7,
|
||||||
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
|
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
|
||||||
},
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 8,
|
||||||
|
resource: self.empty_profile.as_entire_binding(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1818,6 +1888,7 @@ mod tests {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -2021,6 +2092,7 @@ mod tests {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -2629,6 +2701,7 @@ mod tests {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -2732,6 +2805,7 @@ mod tests {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ pub struct DemosaicedImage {
|
|||||||
height: u32,
|
height: u32,
|
||||||
/// Carried through for the camera→sRGB transform in the adjust pass.
|
/// Carried through for the camera→sRGB transform in the adjust pass.
|
||||||
color_matrix: [f32; 9],
|
color_matrix: [f32; 9],
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The camera profile's tables, carried through with the matrix for the
|
||||||
|
/// adjust pass to upload (D20). `None` for a JPEG and for a raw with no
|
||||||
|
/// profile.
|
||||||
|
profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
|
||||||
/// As-shot white balance, the neutral starting point for the WB control.
|
/// As-shot white balance, the neutral starting point for the WB control.
|
||||||
as_shot_wb: [f32; 3],
|
as_shot_wb: [f32; 3],
|
||||||
/// Whether the texture holds gamma-encoded rather than linear values.
|
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||||
@@ -207,6 +212,12 @@ impl DemosaicedImage {
|
|||||||
self.color_matrix
|
self.color_matrix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The camera profile's tables this source renders through, if any.
|
||||||
|
pub fn profile_tables(&self) -> Option<&std::sync::Arc<dr_types::ProfileTables>> {
|
||||||
|
self.profile_tables.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
/// As-shot white balance multipliers, green-normalised.
|
/// As-shot white balance multipliers, green-normalised.
|
||||||
///
|
///
|
||||||
/// The white balance control is expressed *relative* to these, so its
|
/// The white balance control is expressed *relative* to these, so its
|
||||||
@@ -318,6 +329,7 @@ impl DemosaicedImage {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
color_matrix: IDENTITY_3X3,
|
color_matrix: IDENTITY_3X3,
|
||||||
|
profile_tables: None,
|
||||||
as_shot_wb: [1.0, 1.0, 1.0],
|
as_shot_wb: [1.0, 1.0, 1.0],
|
||||||
// **The identity, and this is the whole reason the field is here
|
// **The identity, and this is the whole reason the field is here
|
||||||
// rather than resolved further down.** A JPEG has already been
|
// rather than resolved further down.** A JPEG has already been
|
||||||
@@ -478,6 +490,7 @@ impl DemosaicedImage {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||||
|
profile_tables: raw.profile_tables.clone(),
|
||||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||||
non_linear: false,
|
non_linear: false,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
@@ -892,6 +905,7 @@ impl Demosaicer {
|
|||||||
// Identity where the body is uncalibrated: the image renders with
|
// Identity where the body is uncalibrated: the image renders with
|
||||||
// no colour transform rather than not at all.
|
// no colour transform rather than not at all.
|
||||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||||
|
profile_tables: raw.profile_tables.clone(),
|
||||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||||
// resolved at decode because that is the only place the make and
|
// resolved at decode because that is the only place the make and
|
||||||
@@ -1399,6 +1413,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -1514,6 +1529,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -1810,6 +1826,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -1894,6 +1911,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
+109
-8
@@ -30,18 +30,27 @@
|
|||||||
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
||||||
//! as it is needed, and a caller short of memory may demosaic on demand.
|
//! as it is needed, and a caller short of memory may demosaic on demand.
|
||||||
//!
|
//!
|
||||||
|
//! # The blend
|
||||||
|
//!
|
||||||
|
//! With a seam map (`dr_pano::seam`), a frame's weight at a pixel is its
|
||||||
|
//! share of the map about that pixel — whole on its own side of a seam,
|
||||||
|
//! nothing on the other, and a ramp across a window `seam_blend` pixels
|
||||||
|
//! wide that follows the seam. Without one, or where the map has nothing
|
||||||
|
//! to say, the weight is the distance to the frame's edge over `feather`,
|
||||||
|
//! which hides exposure steps and does not hide parallax: the average draws
|
||||||
|
//! anything the frames disagree on twice.
|
||||||
|
//!
|
||||||
//! # What is not here yet
|
//! # What is not here yet
|
||||||
//!
|
//!
|
||||||
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
|
//! A Laplacian pyramid, which would let the seam's blend be narrow for
|
||||||
//! distance to the frame's edge, which hides exposure steps and small
|
//! detail and wide for exposure at once. Gain is a scalar per frame the
|
||||||
//! misalignments and does not hide parallax. Gain is a scalar per frame
|
//! caller supplies.
|
||||||
//! the caller supplies. Both are panorama.md §10's step 5, after the path
|
|
||||||
//! writes a file end to end.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use dr_pano::bundle::Cameras;
|
use dr_pano::bundle::Cameras;
|
||||||
use dr_pano::projection::{Bounds, Projection};
|
use dr_pano::projection::{Bounds, Projection};
|
||||||
|
use dr_pano::seam::SeamMap;
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::readback::await_mapping;
|
use crate::readback::await_mapping;
|
||||||
@@ -58,7 +67,7 @@ pub struct MergeFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The output the merge produces.
|
/// The output the merge produces.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct MergeOutput {
|
pub struct MergeOutput {
|
||||||
pub projection: Projection,
|
pub projection: Projection,
|
||||||
/// The projection's scale in output pixels: the cylinder's radius, the
|
/// The projection's scale in output pixels: the cylinder's radius, the
|
||||||
@@ -69,6 +78,11 @@ pub struct MergeOutput {
|
|||||||
pub bounds: Bounds,
|
pub bounds: Bounds,
|
||||||
/// Pixels over which a frame's weight ramps up from its edge.
|
/// Pixels over which a frame's weight ramps up from its edge.
|
||||||
pub feather: f32,
|
pub feather: f32,
|
||||||
|
/// Which frame each part of the output is taken from, laid out at the
|
||||||
|
/// proxies' scale; `None` for the feathered average everywhere.
|
||||||
|
pub seams: Option<Arc<SeamMap>>,
|
||||||
|
/// The width, in output pixels, of the blend across a seam.
|
||||||
|
pub seam_blend: f32,
|
||||||
/// Chunk size: the unit of GPU work and of memory.
|
/// Chunk size: the unit of GPU work and of memory.
|
||||||
pub chunk: (u32, u32),
|
pub chunk: (u32, u32),
|
||||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||||
@@ -115,6 +129,12 @@ struct WarpParams {
|
|||||||
feather: f32,
|
feather: f32,
|
||||||
clip_onset: f32,
|
clip_onset: f32,
|
||||||
balance: [f32; 4],
|
balance: [f32; 4],
|
||||||
|
seam_origin: [f32; 2],
|
||||||
|
seam_size: [u32; 2],
|
||||||
|
seam_px: f32,
|
||||||
|
seam_radius: f32,
|
||||||
|
frame_index: u32,
|
||||||
|
seam_on: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -182,6 +202,16 @@ impl MergePass {
|
|||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
storage(2, false),
|
storage(2, false),
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Uint,
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let resolve_layout =
|
let resolve_layout =
|
||||||
@@ -279,6 +309,32 @@ impl MergePass {
|
|||||||
let mut band_cov = vec![false; (out_w * ch) as usize];
|
let mut band_cov = vec![false; (out_w * ch) as usize];
|
||||||
let mut chunk_px: Vec<u32> = Vec::new();
|
let mut chunk_px: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
// The seam map, once for the whole output, and where it sits in
|
||||||
|
// this output's coordinates. A one-texel stand-in when there is
|
||||||
|
// none, because the binding is not optional.
|
||||||
|
let (seam_tex, seam_origin, seam_px, seam_radius, seam_size) = match &output.seams {
|
||||||
|
Some(m) => {
|
||||||
|
let ((ou, ov), px) = m.at_scale(output.scale);
|
||||||
|
let radius = m.blend_radius(output.scale, f64::from(output.seam_blend));
|
||||||
|
(
|
||||||
|
self.label_texture(m.width as u32, m.height as u32, &m.labels),
|
||||||
|
[ou as f32, ov as f32],
|
||||||
|
px as f32,
|
||||||
|
radius as f32,
|
||||||
|
[m.width as u32, m.height as u32],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
self.label_texture(1, 1, &[dr_pano::seam::NONE]),
|
||||||
|
[0.0; 2],
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
[1, 1],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let seam_view = seam_tex.create_view(&Default::default());
|
||||||
|
let seam_on = u32::from(output.seams.is_some());
|
||||||
|
|
||||||
let mut y = 0u32;
|
let mut y = 0u32;
|
||||||
while y < out_h {
|
while y < out_h {
|
||||||
let rows = ch.min(out_h - y);
|
let rows = ch.min(out_h - y);
|
||||||
@@ -346,8 +402,14 @@ impl MergePass {
|
|||||||
output.balance[2].max(1e-3),
|
output.balance[2].max(1e-3),
|
||||||
0.0,
|
0.0,
|
||||||
],
|
],
|
||||||
|
seam_origin,
|
||||||
|
seam_size,
|
||||||
|
seam_px,
|
||||||
|
seam_radius,
|
||||||
|
frame_index: k as u32,
|
||||||
|
seam_on,
|
||||||
};
|
};
|
||||||
self.accumulate(¶ms, tile);
|
self.accumulate(¶ms, tile, &seam_view);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
||||||
@@ -385,7 +447,42 @@ impl MergePass {
|
|||||||
self.ctx.queue.submit(Some(enc.finish()));
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) {
|
/// The seam map's labels as an `r8uint` texture.
|
||||||
|
fn label_texture(&self, width: u32, height: u32, labels: &[u8]) -> wgpu::Texture {
|
||||||
|
let size = wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
};
|
||||||
|
let tex = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("merge-seams"),
|
||||||
|
size,
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R8Uint,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
self.ctx.queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &tex,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
labels,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(width),
|
||||||
|
rows_per_image: Some(height),
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
tex
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture, seams: &wgpu::TextureView) {
|
||||||
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
||||||
let uniforms = self
|
let uniforms = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -417,6 +514,10 @@ impl MergePass {
|
|||||||
binding: 2,
|
binding: 2,
|
||||||
resource: acc.as_entire_binding(),
|
resource: acc.as_entire_binding(),
|
||||||
},
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(seams),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
// pixel it asks which direction that pixel looks along, turns the
|
// pixel it asks which direction that pixel looks along, turns the
|
||||||
// direction into the frame's camera, projects it to a source pixel, and
|
// direction into the frame's camera, projects it to a source pixel, and
|
||||||
// if that pixel is inside the tile that was rendered for this chunk,
|
// if that pixel is inside the tile that was rendered for this chunk,
|
||||||
// samples it and adds it — weighted by its distance from the frame's edge
|
// samples it and adds it — weighted by the frame's share of the seam map
|
||||||
// — into the accumulator. `resolve` runs once per chunk after every frame
|
// there, or by its distance from the frame's edge where there is no map —
|
||||||
|
// into the accumulator. `resolve` runs once per chunk after every frame
|
||||||
// has been added: divides the sums by the weights and packs the result as
|
// has been added: divides the sums by the weights and packs the result as
|
||||||
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
||||||
//
|
//
|
||||||
@@ -50,12 +51,83 @@ struct Params {
|
|||||||
// white balance the composite will be developed with.
|
// white balance the composite will be developed with.
|
||||||
clip_onset: f32,
|
clip_onset: f32,
|
||||||
balance: vec4<f32>,
|
balance: vec4<f32>,
|
||||||
|
// The seam map (`dr_pano::seam`): where its texel (0, 0)'s corner sits
|
||||||
|
// in this output's centred coordinates, its size, output pixels per
|
||||||
|
// texel, the blend's radius in texels, which frame this dispatch is,
|
||||||
|
// and whether there is a map at all.
|
||||||
|
seam_origin: vec2<f32>,
|
||||||
|
seam_size: vec2<u32>,
|
||||||
|
seam_px: f32,
|
||||||
|
seam_radius: f32,
|
||||||
|
frame_index: u32,
|
||||||
|
seam_on: u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> p: Params;
|
@group(0) @binding(0) var<uniform> p: Params;
|
||||||
@group(0) @binding(1) var tile: texture_2d<f32>;
|
@group(0) @binding(1) var tile: texture_2d<f32>;
|
||||||
// rgb·w summed, then w: four floats per chunk pixel.
|
// rgb·w summed, then w: four floats per chunk pixel.
|
||||||
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
||||||
|
// One frame index per texel, 255 for none.
|
||||||
|
@group(0) @binding(3) var seams: texture_2d<u32>;
|
||||||
|
|
||||||
|
const NO_FRAME: u32 = 255u;
|
||||||
|
|
||||||
|
fn label(i: i32, j: i32) -> u32 {
|
||||||
|
if (i < 0 || j < 0 || i >= i32(p.seam_size.x) || j >= i32(p.seam_size.y)) {
|
||||||
|
return NO_FRAME;
|
||||||
|
}
|
||||||
|
return textureLoad(seams, vec2<i32>(i, j), 0).r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This frame's share of the seam map about output point (u, v): the
|
||||||
|
// tent-weighted fraction of the texels within the radius that it owns, and
|
||||||
|
// the weight of the texels owned by anyone (zero where the map has nothing
|
||||||
|
// to say). `SeamMap::share` verbatim.
|
||||||
|
fn seam_share(u: f32, v: f32) -> vec2<f32> {
|
||||||
|
let x = (u - p.seam_origin.x) / p.seam_px - 0.5;
|
||||||
|
let y = (v - p.seam_origin.y) / p.seam_px - 0.5;
|
||||||
|
let r = max(p.seam_radius, 1.0);
|
||||||
|
let x0 = i32(ceil(x - r));
|
||||||
|
let x1 = i32(floor(x + r));
|
||||||
|
let y0 = i32(ceil(y - r));
|
||||||
|
let y1 = i32(floor(y + r));
|
||||||
|
// Most pixels are nowhere near a seam: if the window's corners, edge
|
||||||
|
// midpoints and centre agree, so does the window. A seam crossing it
|
||||||
|
// has to cross its border, between two of those.
|
||||||
|
let xm = i32(round(x));
|
||||||
|
let ym = i32(round(y));
|
||||||
|
let c = label(xm, ym);
|
||||||
|
if (label(x0, y0) == c && label(x1, y0) == c && label(x0, y1) == c && label(x1, y1) == c
|
||||||
|
&& label(xm, y0) == c && label(xm, y1) == c && label(x0, ym) == c && label(x1, ym) == c) {
|
||||||
|
if (c == NO_FRAME) {
|
||||||
|
return vec2<f32>(0.0, 0.0);
|
||||||
|
}
|
||||||
|
return vec2<f32>(select(0.0, 1.0, c == p.frame_index), 1.0);
|
||||||
|
}
|
||||||
|
var mine = 0.0;
|
||||||
|
var owned = 0.0;
|
||||||
|
for (var j = y0; j <= y1; j = j + 1) {
|
||||||
|
let wy = 1.0 - abs(y - f32(j)) / r;
|
||||||
|
if (wy <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (var i = x0; i <= x1; i = i + 1) {
|
||||||
|
let wx = 1.0 - abs(x - f32(i)) / r;
|
||||||
|
let l = label(i, j);
|
||||||
|
if (wx <= 0.0 || l == NO_FRAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
owned = owned + wx * wy;
|
||||||
|
if (l == p.frame_index) {
|
||||||
|
mine = mine + wx * wy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (owned <= 0.0) {
|
||||||
|
return vec2<f32>(0.0, 0.0);
|
||||||
|
}
|
||||||
|
return vec2<f32>(mine / owned, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
||||||
let s = p.proj_scale;
|
let s = p.proj_scale;
|
||||||
@@ -98,7 +170,17 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
if (edge <= 0.0) {
|
if (edge <= 0.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
var w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||||
|
// With seams, the share of the map scales it. The small floor keeps
|
||||||
|
// the feather underneath as the answer wherever no frame that reaches
|
||||||
|
// this pixel owns it — the map is coarser than the output, so at the
|
||||||
|
// frames' outer edges it can name a frame that falls just short.
|
||||||
|
if (p.seam_on != 0u) {
|
||||||
|
let s = seam_share(u, v);
|
||||||
|
if (s.y > 0.0) {
|
||||||
|
w = w * (s.x + 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Into the tile.
|
// Into the tile.
|
||||||
let tx = sx - p.tile_origin.x;
|
let tx = sx - p.tile_origin.x;
|
||||||
let ty = sy - p.tile_origin.y;
|
let ty = sy - p.tile_origin.y;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
//! TRACES: FR-DEV-3e
|
||||||
|
//! The camera profile's tables, end to end on a device (D20).
|
||||||
|
//!
|
||||||
|
//! `dr-pipeline` holds the lookup to the DNG SDK's algorithm on the CPU
|
||||||
|
//! (`ops::camera_profile::apply_reference`). Nothing there would notice a
|
||||||
|
//! shader that disagreed with it — a transposed constant matrix, an index
|
||||||
|
//! off by one column, a buffer bound in the wrong order — so this renders a
|
||||||
|
//! frame of 256 different colours through tables that move every one of them
|
||||||
|
//! a long way, and holds each pixel to the reference.
|
||||||
|
//!
|
||||||
|
//! The source is a linear three-sample frame, so the colours arrive exactly
|
||||||
|
//! as written with no demosaic between, and an identity stands in the view
|
||||||
|
//! transform's place so the readback is the scene colour, display-encoded.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||||
|
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId};
|
||||||
|
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||||||
|
use dr_pipeline::ops::camera_profile::{apply_reference, CameraProfile, APPLY, LOOK};
|
||||||
|
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables, Transfer};
|
||||||
|
|
||||||
|
const SIZE: u32 = 16;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
pollster::block_on(GpuContext::new_headless()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An identity in the view transform's place.
|
||||||
|
struct IdentityView;
|
||||||
|
|
||||||
|
impl Operation for IdentityView {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
Arc::new(OpDescriptor {
|
||||||
|
id: OpId("identity_view"),
|
||||||
|
label: LocalizedKey("identity_view"),
|
||||||
|
params: Vec::new(),
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn set_param(&mut self, _: ParamId, _: f32) {}
|
||||||
|
fn param(&self, _: ParamId) -> f32 {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
Stage::View
|
||||||
|
}
|
||||||
|
fn renders(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 256 colours across hue, saturation and value, kept under the prologue's
|
||||||
|
/// highlight desaturation and above black.
|
||||||
|
fn colours() -> Vec<[f32; 3]> {
|
||||||
|
(0..SIZE * SIZE)
|
||||||
|
.map(|i| {
|
||||||
|
let f = |k: u32| {
|
||||||
|
let x = (i.wrapping_mul(2_654_435_761).rotate_left(k * 7) >> 8) % 1000;
|
||||||
|
0.04 + 0.86 * x as f32 / 1000.0
|
||||||
|
};
|
||||||
|
[f(1), f(2), f(3)]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(tables: Option<ProfileTables>) -> RawImage {
|
||||||
|
let data = colours()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|c| c.map(|v| (v * 65535.0).round() as u16))
|
||||||
|
.collect();
|
||||||
|
RawImage {
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
data,
|
||||||
|
cfa_pattern: CfaPattern::Rggb,
|
||||||
|
black_level: [0; 4],
|
||||||
|
white_level: u16::MAX,
|
||||||
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
|
samples_per_pixel: 3,
|
||||||
|
profile: None,
|
||||||
|
profile_tables: tables.map(Arc::new),
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
|
crop: CropRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tables that move every colour by a different amount: hue shifts of tens
|
||||||
|
/// of degrees, saturation scales either side of one, and a 3-D, sRGB-indexed
|
||||||
|
/// look whose value scale varies down the value axis.
|
||||||
|
fn strong_tables() -> ProfileTables {
|
||||||
|
let (hd, sd) = (12u32, 5u32);
|
||||||
|
let hue_sat = (0..hd * sd)
|
||||||
|
.map(|i| {
|
||||||
|
let (h, s) = (i / sd, i % sd);
|
||||||
|
let a = h as f32 / hd as f32 * std::f32::consts::TAU;
|
||||||
|
[25.0 * a.sin(), 1.0 + 0.3 * a.cos() * s as f32 / 4.0, 1.0]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let (lh, ls, lv) = (8u32, 4u32, 5u32);
|
||||||
|
let look = (0..lh * ls * lv)
|
||||||
|
.map(|i| {
|
||||||
|
let v = i / (lh * ls);
|
||||||
|
let h = (i / ls) % lh;
|
||||||
|
[
|
||||||
|
-15.0 + 4.0 * h as f32,
|
||||||
|
1.25 - 0.05 * v as f32,
|
||||||
|
0.85 + 0.06 * v as f32,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut look = HueSatTable::new(lh, ls, lv, true, look).unwrap();
|
||||||
|
look.srgb_encoded = true;
|
||||||
|
ProfileTables {
|
||||||
|
name: "strong".into(),
|
||||||
|
origin: ProfileOrigin::Embedded,
|
||||||
|
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
|
||||||
|
look: Some(look),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(ctx: &GpuContext, raw: &RawImage, op: CameraProfile) -> Vec<[u8; 3]> {
|
||||||
|
let source = Demosaicer::new(ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.run(raw)
|
||||||
|
.expect("upload");
|
||||||
|
let ops: Vec<Box<dyn Operation>> = vec![Box::new(op), Box::new(IdentityView)];
|
||||||
|
let shader = dr_pipeline::compose(&ops);
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||||
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
|
pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(c: [f32; 3]) -> [i32; 3] {
|
||||||
|
c.map(|v| (Transfer::Srgb.encode(v.clamp(0.0, 1.0)) * 255.0).round() as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_agrees(got: &[[u8; 3]], expected: impl Fn([f32; 3]) -> [f32; 3], what: &str) {
|
||||||
|
let mut moved = 0;
|
||||||
|
for (i, (c, g)) in colours().into_iter().zip(got).enumerate() {
|
||||||
|
let want = encode(expected(c));
|
||||||
|
let g = g.map(i32::from);
|
||||||
|
// Two 8-bit steps: the half-float source and intermediate, and the
|
||||||
|
// rounding either side of the encode.
|
||||||
|
assert!(
|
||||||
|
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
|
||||||
|
"{what}: pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
|
||||||
|
);
|
||||||
|
if want != encode(c) {
|
||||||
|
moved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
moved > 200,
|
||||||
|
"{what}: only {moved} of 256 colours moved; the test proves little"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shader_agrees_with_the_cpu_reference() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tables = strong_tables();
|
||||||
|
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
|
||||||
|
assert_agrees(&got, |c| apply_reference(&tables, c, 1.0), "at defaults");
|
||||||
|
|
||||||
|
let mut doubled = CameraProfile::new();
|
||||||
|
doubled.set_param(LOOK, 200.0);
|
||||||
|
let got = render(&ctx, &frame(Some(tables.clone())), doubled);
|
||||||
|
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switched_off_or_absent_the_render_is_unchanged() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bare = render(&ctx, &frame(None), CameraProfile::new());
|
||||||
|
let mut off = CameraProfile::new();
|
||||||
|
off.set_param(APPLY, 0.0);
|
||||||
|
let switched_off = render(&ctx, &frame(Some(strong_tables())), off);
|
||||||
|
assert_eq!(bare, switched_off, "the switch off is the matrix alone");
|
||||||
|
// Against the source colours, one 8-bit step for the half-float texture
|
||||||
|
// the source is uploaded in; the exact comparison is the one above.
|
||||||
|
for (c, g) in colours().into_iter().zip(&bare) {
|
||||||
|
let want = encode(c);
|
||||||
|
assert!(
|
||||||
|
want.iter()
|
||||||
|
.zip(g)
|
||||||
|
.all(|(w, g)| (w - i32::from(*g)).abs() <= 1),
|
||||||
|
"no tables, no change: {c:?} rendered {g:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_libraries_adobe_standard_renders_as_the_reference_does() {
|
||||||
|
// The real tables, when the library's 6D DNG is on this machine: a 90×30
|
||||||
|
// HueSatMap and a 36×8×16 LookTable, at the sizes no synthetic test
|
||||||
|
// reaches.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = std::env::var_os("DR_DCP_SAMPLE")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var_os("HOME").map(|h| {
|
||||||
|
std::path::PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
|
||||||
|
eprintln!("skipping: no sample DNG");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tables = dr_decode::dcp::embedded_in(&bytes)
|
||||||
|
.expect("Adobe Standard")
|
||||||
|
.tables(5000.0, ProfileOrigin::Embedded);
|
||||||
|
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
|
||||||
|
for (i, (c, g)) in colours().into_iter().zip(&got).enumerate() {
|
||||||
|
let want = encode(apply_reference(&tables, c, 1.0));
|
||||||
|
let g = g.map(i32::from);
|
||||||
|
assert!(
|
||||||
|
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
|
||||||
|
"pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
|
|||||||
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
//! - [`align`] — the whole thing, from features to cameras, honest about
|
//! - [`align`] — the whole thing, from features to cameras, honest about
|
||||||
//! what it could not place.
|
//! what it could not place.
|
||||||
//! - [`projection`] — perspective, cylindrical, spherical.
|
//! - [`projection`] — perspective, cylindrical, spherical.
|
||||||
|
//! - [`seam`] — which frame each output pixel is taken from.
|
||||||
//! - [`linalg`] — the small dense algebra all of it uses.
|
//! - [`linalg`] — the small dense algebra all of it uses.
|
||||||
//!
|
//!
|
||||||
//! # What it depends on
|
//! # What it depends on
|
||||||
@@ -43,6 +44,7 @@ pub mod matching;
|
|||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod migan;
|
pub mod migan;
|
||||||
pub mod projection;
|
pub mod projection;
|
||||||
|
pub mod seam;
|
||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod xfeat;
|
pub mod xfeat;
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ pub use features::{Features, Keypoint};
|
|||||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||||
pub use image::Gray;
|
pub use image::Gray;
|
||||||
pub use projection::Projection;
|
pub use projection::Projection;
|
||||||
|
pub use seam::{SeamMap, SeamOptions};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum PanoError {
|
pub enum PanoError {
|
||||||
|
|||||||
@@ -0,0 +1,691 @@
|
|||||||
|
//! TRACES: FR-MRG-10
|
||||||
|
//! Where each frame gives way to the next.
|
||||||
|
//!
|
||||||
|
//! The first merges averaged every overlap: each frame weighted by its
|
||||||
|
//! distance from its own edge, so that across two hundred pixels one frame
|
||||||
|
//! faded into the other. That hides an exposure step and does not hide
|
||||||
|
//! anything that differs between the frames — parallax on a near slope, a
|
||||||
|
//! walker, a branch in the wind — which the average draws twice, half as
|
||||||
|
//! bright, a soft double edge at 1:1.
|
||||||
|
//!
|
||||||
|
//! A seam answers it the way every stitcher does: in an overlap, each output
|
||||||
|
//! pixel is taken from *one* frame, and the line where the choice changes is
|
||||||
|
//! put where the frames agree and the picture is smooth — through sky,
|
||||||
|
//! along a shadow, round the walker rather than through him — and away from
|
||||||
|
//! either frame's edge, where vignetting and the lens correction's fringe
|
||||||
|
//! live. The blend is then narrow and only across that line.
|
||||||
|
//!
|
||||||
|
//! # How
|
||||||
|
//!
|
||||||
|
//! At proxy resolution, on the output surface, which fits (panorama.md §5:
|
||||||
|
//! "it is a mask, not an image"):
|
||||||
|
//!
|
||||||
|
//! 1. Frames are laid down one at a time, each next to one already placed.
|
||||||
|
//! The composite so far is a label per texel and the value its owner saw.
|
||||||
|
//! 2. Where a new frame overlaps the composite, a cost per texel: the
|
||||||
|
//! difference between the two (after the gains), how much detail either
|
||||||
|
//! has there, and how near either frame's edge it is — smoothed over a
|
||||||
|
//! few texels, because "agree" means locally, not at one pixel.
|
||||||
|
//! 3. The cut is a path across the overlap, perpendicular to the line from
|
||||||
|
//! the composite's frames to the new one, found by dynamic programming
|
||||||
|
//! one row at a time: the per-column seam panorama.md §4 chose over a
|
||||||
|
//! graph cut because it is the GPU-friendly shape. Texels on the new
|
||||||
|
//! frame's side of the path become its own.
|
||||||
|
//!
|
||||||
|
//! What the merge reads is [`SeamMap::share`]: the fraction of a small
|
||||||
|
//! window about a point that is labelled with a frame, tent-weighted, which
|
||||||
|
//! is a narrow blend that follows the seam. `merge.wgsl` computes the same
|
||||||
|
//! thing on the GPU from the same labels.
|
||||||
|
|
||||||
|
use crate::bundle::Cameras;
|
||||||
|
use crate::image::Gray;
|
||||||
|
use crate::projection::{self, Projection};
|
||||||
|
|
||||||
|
/// No frame owns this texel.
|
||||||
|
pub const NONE: u8 = 255;
|
||||||
|
|
||||||
|
/// The most frames a map can label: one less than [`NONE`].
|
||||||
|
pub const MAX_FRAMES: usize = NONE as usize;
|
||||||
|
|
||||||
|
/// Which frame each texel of the output takes its pixels from.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct SeamMap {
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
/// The projection scale the map was laid out at: the proxies' focal
|
||||||
|
/// length. Output coordinates at any other scale are this times the
|
||||||
|
/// ratio of the scales.
|
||||||
|
pub scale: f64,
|
||||||
|
/// Centred output coordinates, at `scale`, of texel (0, 0)'s top-left
|
||||||
|
/// corner.
|
||||||
|
pub origin: (f64, f64),
|
||||||
|
/// Output units per texel, at `scale`.
|
||||||
|
pub px: f64,
|
||||||
|
/// Row-major, one per texel: the frame's index, or [`NONE`].
|
||||||
|
pub labels: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct SeamOptions {
|
||||||
|
/// The widest the map is laid out, in texels. Wider than the proxies'
|
||||||
|
/// own resolution buys nothing.
|
||||||
|
pub max_width: usize,
|
||||||
|
/// How much detail costs against disagreement: a seam through texture
|
||||||
|
/// shows even where the frames agree, because the blend across it
|
||||||
|
/// softens it.
|
||||||
|
pub detail: f32,
|
||||||
|
/// How much a frame's edge costs, and how far in from it the cost
|
||||||
|
/// reaches, in proxy pixels. Frame edges are where vignetting is
|
||||||
|
/// darkest and the lens correction ran out of sensor.
|
||||||
|
pub edge: f32,
|
||||||
|
pub edge_margin: f32,
|
||||||
|
/// The radius, in texels, a texel's cost looks about it for the worst
|
||||||
|
/// of its neighbours: at least the radius the merge blends across.
|
||||||
|
pub smoothing: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SeamOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
SeamOptions {
|
||||||
|
max_width: 2048,
|
||||||
|
detail: 0.5,
|
||||||
|
edge: 0.5,
|
||||||
|
edge_margin: 24.0,
|
||||||
|
smoothing: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most texels a blend reaches either side of a seam. The merge's
|
||||||
|
/// shader loads the square of twice this per pixel per frame near a seam.
|
||||||
|
pub const MAX_BLEND_RADIUS: f64 = 4.0;
|
||||||
|
|
||||||
|
/// Cost of a texel outside the overlap: high enough that the path keeps to
|
||||||
|
/// the overlap wherever there is one, finite so that a row with a gap in it
|
||||||
|
/// still has an answer.
|
||||||
|
const OUTSIDE: f32 = 1.0e3;
|
||||||
|
|
||||||
|
impl SeamMap {
|
||||||
|
/// The map's origin and texel size in the coordinates of an output
|
||||||
|
/// laid out at `scale` (the full-resolution focal length, or a fraction
|
||||||
|
/// of it).
|
||||||
|
pub fn at_scale(&self, scale: f64) -> ((f64, f64), f64) {
|
||||||
|
let r = scale / self.scale;
|
||||||
|
((self.origin.0 * r, self.origin.1 * r), self.px * r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The radius, in texels, of a blend `blend_px` output pixels wide in an
|
||||||
|
/// output laid out at `scale`: what [`Self::share`] and the shader are
|
||||||
|
/// given, so that the preview and the merge blend alike.
|
||||||
|
pub fn blend_radius(&self, scale: f64, blend_px: f64) -> f64 {
|
||||||
|
let (_, px) = self.at_scale(scale);
|
||||||
|
(blend_px / 2.0 / px).clamp(1.0, MAX_BLEND_RADIUS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share frame `k` has of output point `(u, v)` given at `scale`:
|
||||||
|
/// the tent-weighted fraction of the texels within `radius` (in texels)
|
||||||
|
/// that it owns. `None` where no texel in reach is owned at all — the
|
||||||
|
/// map has nothing to say there, and the caller falls back to its
|
||||||
|
/// feather.
|
||||||
|
///
|
||||||
|
/// This is the function `merge.wgsl`'s `seam_share` repeats; the two
|
||||||
|
/// must agree.
|
||||||
|
pub fn share(&self, k: usize, u: f64, v: f64, scale: f64, radius: f64) -> Option<f32> {
|
||||||
|
let ((ou, ov), px) = self.at_scale(scale);
|
||||||
|
let x = (u - ou) / px - 0.5;
|
||||||
|
let y = (v - ov) / px - 0.5;
|
||||||
|
let r = radius.max(1.0);
|
||||||
|
let (x0, x1) = ((x - r).ceil() as i64, (x + r).floor() as i64);
|
||||||
|
let (y0, y1) = ((y - r).ceil() as i64, (y + r).floor() as i64);
|
||||||
|
let (mut mine, mut all) = (0.0f64, 0.0f64);
|
||||||
|
for j in y0.max(0)..=y1.min(self.height as i64 - 1) {
|
||||||
|
let wy = 1.0 - (y - j as f64).abs() / r;
|
||||||
|
if wy <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for i in x0.max(0)..=x1.min(self.width as i64 - 1) {
|
||||||
|
let wx = 1.0 - (x - i as f64).abs() / r;
|
||||||
|
if wx <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let l = self.labels[j as usize * self.width + i as usize];
|
||||||
|
if l == NONE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
all += wx * wy;
|
||||||
|
if usize::from(l) == k {
|
||||||
|
mine += wx * wy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(all > 0.0).then(|| (mine / all) as f32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame warped onto the map: its gain-corrected value and its distance
|
||||||
|
/// from its own edge (in proxy pixels) per texel, NaN where it does not
|
||||||
|
/// reach.
|
||||||
|
struct Warped {
|
||||||
|
value: Vec<f32>,
|
||||||
|
edge: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay seams across the overlaps of `proxies`, aligned by `cameras` (at the
|
||||||
|
/// proxies' scale), with `gains` the linear multipliers the merge will
|
||||||
|
/// apply. `None` if the frames project nowhere or there are more than
|
||||||
|
/// [`MAX_FRAMES`].
|
||||||
|
pub fn find(
|
||||||
|
proxies: &[&Gray],
|
||||||
|
cameras: &Cameras,
|
||||||
|
gains: &[f32],
|
||||||
|
projection: Projection,
|
||||||
|
opts: &SeamOptions,
|
||||||
|
) -> Option<SeamMap> {
|
||||||
|
let n = proxies.len();
|
||||||
|
if n == 0 || n > MAX_FRAMES || cameras.rotations.len() != n || gains.len() != n {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||||
|
let scale = cameras.focal;
|
||||||
|
let bounds = projection::bounds(projection, scale, cameras, (fw, fh))?;
|
||||||
|
let width = opts.max_width.min(bounds.width().ceil() as usize).max(1);
|
||||||
|
let px = bounds.width() / width as f64;
|
||||||
|
let height = ((bounds.height() / px).ceil() as usize).max(1);
|
||||||
|
let mut map = SeamMap {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale,
|
||||||
|
origin: (bounds.min_u, bounds.min_v),
|
||||||
|
px,
|
||||||
|
labels: vec![NONE; width * height],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where each frame's centre lands, in texels: what orders the frames
|
||||||
|
// and orients each cut.
|
||||||
|
let centres: Vec<(f64, f64)> = (0..n)
|
||||||
|
.map(|k| {
|
||||||
|
let d = cameras.bearing(k, (0.0, 0.0));
|
||||||
|
projection
|
||||||
|
.from_direction(scale, d)
|
||||||
|
.map(|(u, v)| ((u - bounds.min_u) / px, (v - bounds.min_v) / px))
|
||||||
|
.unwrap_or((width as f64 / 2.0, height as f64 / 2.0))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// The composite so far: what its owner saw, and how far from the
|
||||||
|
// owner's edge.
|
||||||
|
let mut value = vec![f32::NAN; width * height];
|
||||||
|
let mut edge = vec![f32::NAN; width * height];
|
||||||
|
|
||||||
|
for k in order(¢res, (width as f64 / 2.0, height as f64 / 2.0)) {
|
||||||
|
let w = warp(&map, proxies[k], cameras, k, gains[k], projection);
|
||||||
|
let overlap: Vec<usize> = (0..width * height)
|
||||||
|
.filter(|&i| map.labels[i] != NONE && !w.value[i].is_nan())
|
||||||
|
.collect();
|
||||||
|
// Texels nobody owns yet are the new frame's without a cut.
|
||||||
|
let mut take: Vec<bool> = map
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.zip(&w.value)
|
||||||
|
.map(|(&l, v)| l == NONE && !v.is_nan())
|
||||||
|
.collect();
|
||||||
|
if !overlap.is_empty() {
|
||||||
|
cut(
|
||||||
|
&map, &value, &edge, &w, &overlap, ¢res, k, opts, &mut take,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for i in 0..width * height {
|
||||||
|
if take[i] {
|
||||||
|
map.labels[i] = k as u8;
|
||||||
|
value[i] = w.value[i];
|
||||||
|
edge[i] = w.edge[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order frames are laid down in: the one nearest the middle first,
|
||||||
|
/// then always the unplaced frame nearest any placed one, so that each new
|
||||||
|
/// frame meets the composite along an overlap rather than across a gap.
|
||||||
|
fn order(centres: &[(f64, f64)], middle: (f64, f64)) -> Vec<usize> {
|
||||||
|
let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2);
|
||||||
|
let n = centres.len();
|
||||||
|
let mut placed = vec![false; n];
|
||||||
|
let mut out = Vec::with_capacity(n);
|
||||||
|
let first = (0..n)
|
||||||
|
.min_by(|&a, &b| d2(centres[a], middle).total_cmp(&d2(centres[b], middle)))
|
||||||
|
.expect("at least one frame");
|
||||||
|
placed[first] = true;
|
||||||
|
out.push(first);
|
||||||
|
while out.len() < n {
|
||||||
|
let next = (0..n)
|
||||||
|
.filter(|&k| !placed[k])
|
||||||
|
.min_by(|&a, &b| {
|
||||||
|
let near = |k: usize| {
|
||||||
|
out.iter()
|
||||||
|
.map(|&p| d2(centres[k], centres[p]))
|
||||||
|
.fold(f64::MAX, f64::min)
|
||||||
|
};
|
||||||
|
near(a).total_cmp(&near(b))
|
||||||
|
})
|
||||||
|
.expect("an unplaced frame");
|
||||||
|
placed[next] = true;
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frame `k` sampled at every texel's centre, bilinearly. The proxy is
|
||||||
|
/// gamma-encoded grey, so the gain (linear) becomes `gain^(1/2.2)` on it.
|
||||||
|
fn warp(
|
||||||
|
map: &SeamMap,
|
||||||
|
g: &Gray,
|
||||||
|
cameras: &Cameras,
|
||||||
|
k: usize,
|
||||||
|
gain: f32,
|
||||||
|
projection: Projection,
|
||||||
|
) -> Warped {
|
||||||
|
let (fw, fh) = (g.width as f64, g.height as f64);
|
||||||
|
let gain = gain.max(1e-6).powf(1.0 / 2.2);
|
||||||
|
let mut value = vec![f32::NAN; map.width * map.height];
|
||||||
|
let mut edge = vec![f32::NAN; map.width * map.height];
|
||||||
|
for ty in 0..map.height {
|
||||||
|
let v = map.origin.1 + (ty as f64 + 0.5) * map.px;
|
||||||
|
for tx in 0..map.width {
|
||||||
|
let u = map.origin.0 + (tx as f64 + 0.5) * map.px;
|
||||||
|
let d = projection.to_direction(map.scale, u, v);
|
||||||
|
let Some((x, y)) = cameras.project(k, d) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (x, y) = (x + fw / 2.0 - 0.5, y + fh / 2.0 - 0.5);
|
||||||
|
let e = x.min(fw - 1.0 - x).min(y).min(fh - 1.0 - y);
|
||||||
|
if e < 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
|
||||||
|
let (x1, y1) = ((x0 + 1).min(g.width - 1), (y0 + 1).min(g.height - 1));
|
||||||
|
let (ax, ay) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
||||||
|
let at = |xx: usize, yy: usize| g.data[yy * g.width + xx];
|
||||||
|
let top = at(x0, y0) * (1.0 - ax) + at(x1, y0) * ax;
|
||||||
|
let bot = at(x0, y1) * (1.0 - ax) + at(x1, y1) * ax;
|
||||||
|
let i = ty * map.width + tx;
|
||||||
|
value[i] = (top * (1.0 - ay) + bot * ay) * gain;
|
||||||
|
edge[i] = e as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Warped { value, edge }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central-difference gradient magnitude of `plane` at texel `i`, from the
|
||||||
|
/// neighbours that exist.
|
||||||
|
fn detail(plane: &[f32], width: usize, height: usize, i: usize) -> f32 {
|
||||||
|
let (x, y) = (i % width, i / width);
|
||||||
|
let c = plane[i];
|
||||||
|
let mut g = 0.0f32;
|
||||||
|
let mut diff = |j: usize| {
|
||||||
|
let n = plane[j];
|
||||||
|
if !n.is_nan() {
|
||||||
|
g = g.max((n - c).abs());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if x > 0 {
|
||||||
|
diff(i - 1);
|
||||||
|
}
|
||||||
|
if x + 1 < width {
|
||||||
|
diff(i + 1);
|
||||||
|
}
|
||||||
|
if y > 0 {
|
||||||
|
diff(i - width);
|
||||||
|
}
|
||||||
|
if y + 1 < height {
|
||||||
|
diff(i + width);
|
||||||
|
}
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut the overlap between the composite and frame `k`, marking in `take`
|
||||||
|
/// the overlap texels that go to `k`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn cut(
|
||||||
|
map: &SeamMap,
|
||||||
|
value: &[f32],
|
||||||
|
edge: &[f32],
|
||||||
|
new: &Warped,
|
||||||
|
overlap: &[usize],
|
||||||
|
centres: &[(f64, f64)],
|
||||||
|
k: usize,
|
||||||
|
opts: &SeamOptions,
|
||||||
|
take: &mut [bool],
|
||||||
|
) {
|
||||||
|
let (w, h) = (map.width, map.height);
|
||||||
|
|
||||||
|
// The raw cost per overlap texel.
|
||||||
|
let mut raw = vec![f32::NAN; w * h];
|
||||||
|
let margin = opts.edge_margin.max(1.0);
|
||||||
|
for &i in overlap {
|
||||||
|
let differ = (value[i] - new.value[i]).abs();
|
||||||
|
let detail = detail(value, w, h, i).max(detail(&new.value, w, h, i));
|
||||||
|
let near = (1.0 - edge[i].min(new.edge[i]) / margin).max(0.0);
|
||||||
|
raw[i] = differ + opts.detail * detail + opts.edge * near * near + 1e-3;
|
||||||
|
}
|
||||||
|
// The worst over a small window: a texel is only cheap if its whole
|
||||||
|
// neighbourhood agrees, so the path keeps at least the blend's radius
|
||||||
|
// clear of a difference rather than threading the one lucky texel
|
||||||
|
// beside it — the blend straddles the path by that much and would
|
||||||
|
// otherwise reach the difference anyway.
|
||||||
|
let r = opts.smoothing as isize;
|
||||||
|
let mut cost = vec![OUTSIDE; w * h];
|
||||||
|
for &i in overlap {
|
||||||
|
let (x, y) = ((i % w) as isize, (i / w) as isize);
|
||||||
|
let mut worst = 0.0f32;
|
||||||
|
for dy in -r..=r {
|
||||||
|
for dx in -r..=r {
|
||||||
|
let (xx, yy) = (x + dx, y + dy);
|
||||||
|
if xx < 0 || yy < 0 || xx >= w as isize || yy >= h as isize {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = raw[yy as usize * w + xx as usize];
|
||||||
|
if !c.is_nan() {
|
||||||
|
worst = worst.max(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cost[i] = worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The axis the cut crosses: from the composite's frames, weighted by how
|
||||||
|
// much of the overlap each owns, to the new frame.
|
||||||
|
let mut from = (0.0f64, 0.0f64);
|
||||||
|
for &i in overlap {
|
||||||
|
let c = centres[usize::from(map.labels[i])];
|
||||||
|
from = (from.0 + c.0, from.1 + c.1);
|
||||||
|
}
|
||||||
|
let m = overlap.len() as f64;
|
||||||
|
from = (from.0 / m, from.1 / m);
|
||||||
|
let to = centres[k];
|
||||||
|
let (mut ax, mut ay) = (to.0 - from.0, to.1 - from.1);
|
||||||
|
let len = (ax * ax + ay * ay).sqrt();
|
||||||
|
if len < 1e-6 {
|
||||||
|
(ax, ay) = (1.0, 0.0);
|
||||||
|
} else {
|
||||||
|
(ax, ay) = (ax / len, ay / len);
|
||||||
|
}
|
||||||
|
// Along the cut: perpendicular to the axis.
|
||||||
|
let (bx, by) = (-ay, ax);
|
||||||
|
|
||||||
|
// The overlap's extent in (s along the cut, t across it).
|
||||||
|
let st = |i: usize| {
|
||||||
|
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||||
|
(x * bx + y * by, x * ax + y * ay)
|
||||||
|
};
|
||||||
|
let (mut s0, mut s1, mut t0, mut t1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
|
||||||
|
for &i in overlap {
|
||||||
|
let (s, t) = st(i);
|
||||||
|
s0 = s0.min(s);
|
||||||
|
s1 = s1.max(s);
|
||||||
|
t0 = t0.min(t);
|
||||||
|
t1 = t1.max(t);
|
||||||
|
}
|
||||||
|
let rows = (s1 - s0).round() as usize + 1;
|
||||||
|
let cols = (t1 - t0).round() as usize + 1;
|
||||||
|
|
||||||
|
// The grid in (s, t), each cell sampled from the texel it falls in, so
|
||||||
|
// that a rotated overlap has no holes.
|
||||||
|
let mut grid = vec![OUTSIDE; rows * cols];
|
||||||
|
let mut any = vec![false; rows];
|
||||||
|
for si in 0..rows {
|
||||||
|
for ti in 0..cols {
|
||||||
|
let (s, t) = (s0 + si as f64, t0 + ti as f64);
|
||||||
|
let x = s * bx + t * ax;
|
||||||
|
let y = s * by + t * ay;
|
||||||
|
if x < 0.0 || y < 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (x, y) = (x as usize, y as usize);
|
||||||
|
if x >= w || y >= h {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = cost[y * w + x];
|
||||||
|
if c < OUTSIDE {
|
||||||
|
grid[si * cols + ti] = c;
|
||||||
|
any[si] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic programming down the rows: the path moves at most one column
|
||||||
|
// per row, and starts afresh after a row with no overlap in it.
|
||||||
|
let mut acc = grid.clone();
|
||||||
|
let mut from_col = vec![0u32; rows * cols];
|
||||||
|
for si in 1..rows {
|
||||||
|
if !any[si] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prev = &acc[(si - 1) * cols..si * cols].to_vec();
|
||||||
|
if !any[si - 1] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for ti in 0..cols {
|
||||||
|
let mut best = (prev[ti], ti);
|
||||||
|
if ti > 0 && prev[ti - 1] < best.0 {
|
||||||
|
best = (prev[ti - 1], ti - 1);
|
||||||
|
}
|
||||||
|
if ti + 1 < cols && prev[ti + 1] < best.0 {
|
||||||
|
best = (prev[ti + 1], ti + 1);
|
||||||
|
}
|
||||||
|
acc[si * cols + ti] += best.0;
|
||||||
|
from_col[si * cols + ti] = best.1 as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Back up from the end of each run of rows with overlap.
|
||||||
|
let mut seam = vec![usize::MAX; rows];
|
||||||
|
let mut si = rows;
|
||||||
|
while si > 0 {
|
||||||
|
si -= 1;
|
||||||
|
if !any[si] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let row = &acc[si * cols..(si + 1) * cols];
|
||||||
|
let mut t = (0..cols)
|
||||||
|
.min_by(|&a, &b| row[a].total_cmp(&row[b]))
|
||||||
|
.unwrap_or(0);
|
||||||
|
loop {
|
||||||
|
seam[si] = t;
|
||||||
|
if si == 0 || !any[si - 1] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
t = from_col[si * cols + t] as usize;
|
||||||
|
si -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new frame takes the side of the path its centre is on.
|
||||||
|
for &i in overlap {
|
||||||
|
let (s, t) = st(i);
|
||||||
|
let si = ((s - s0).round() as usize).min(rows - 1);
|
||||||
|
let ti = (t - t0).round();
|
||||||
|
if seam[si] != usize::MAX && ti >= seam[si] as f64 {
|
||||||
|
take[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::linalg::{Mat3, Vec3};
|
||||||
|
|
||||||
|
/// A scene as a function of direction, and frames of it rendered by the
|
||||||
|
/// same cameras the seam reads.
|
||||||
|
fn render(
|
||||||
|
cameras: &Cameras,
|
||||||
|
k: usize,
|
||||||
|
size: (usize, usize),
|
||||||
|
scene: impl Fn(Vec3) -> f32,
|
||||||
|
) -> Gray {
|
||||||
|
let (w, h) = size;
|
||||||
|
let mut data = vec![0.0; w * h];
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let p = (
|
||||||
|
x as f64 + 0.5 - w as f64 / 2.0,
|
||||||
|
y as f64 + 0.5 - h as f64 / 2.0,
|
||||||
|
);
|
||||||
|
data[y * w + x] = scene(cameras.bearing(k, p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Gray {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yaw(a: f64) -> Mat3 {
|
||||||
|
let (s, c) = a.sin_cos();
|
||||||
|
Mat3([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smooth, with a little texture: what a sky over a slope looks like to
|
||||||
|
/// the cost.
|
||||||
|
fn landscape(d: Vec3) -> f32 {
|
||||||
|
let (x, y) = (d.x() / d.z(), d.y() / d.z());
|
||||||
|
let texture = if y > 0.1 { 0.1 * (y * 40.0).sin() } else { 0.0 };
|
||||||
|
(0.5 + 0.2 * (x * 3.0).sin() + texture).clamp(0.0, 1.0) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pair() -> Cameras {
|
||||||
|
Cameras {
|
||||||
|
rotations: vec![Mat3::IDENTITY, yaw(0.35)],
|
||||||
|
focal: 300.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_frame_owns_everything_it_reaches() {
|
||||||
|
let cameras = Cameras {
|
||||||
|
rotations: vec![Mat3::IDENTITY],
|
||||||
|
focal: 300.0,
|
||||||
|
};
|
||||||
|
let g = render(&cameras, 0, (320, 240), landscape);
|
||||||
|
let map = find(
|
||||||
|
&[&g],
|
||||||
|
&cameras,
|
||||||
|
&[1.0],
|
||||||
|
Projection::Perspective,
|
||||||
|
&Default::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let owned = map.labels.iter().filter(|&&l| l == 0).count();
|
||||||
|
assert!(owned as f64 > 0.95 * (map.width * map.height) as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_frame_keeps_its_own_side() {
|
||||||
|
let cameras = pair();
|
||||||
|
let frames: Vec<Gray> = (0..2)
|
||||||
|
.map(|k| render(&cameras, k, (320, 240), landscape))
|
||||||
|
.collect();
|
||||||
|
let refs: Vec<&Gray> = frames.iter().collect();
|
||||||
|
let map = find(
|
||||||
|
&refs,
|
||||||
|
&cameras,
|
||||||
|
&[1.0, 1.0],
|
||||||
|
Projection::Cylindrical,
|
||||||
|
&Default::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mid = map.height / 2 * map.width;
|
||||||
|
assert_eq!(map.labels[mid + 2], 0, "the left edge is frame 0's alone");
|
||||||
|
assert_eq!(
|
||||||
|
map.labels[mid + map.width - 3],
|
||||||
|
1,
|
||||||
|
"the right edge is frame 1's"
|
||||||
|
);
|
||||||
|
// One change of owner along every row that both frames cross.
|
||||||
|
for y in 0..map.height {
|
||||||
|
let row = &map.labels[y * map.width..(y + 1) * map.width];
|
||||||
|
let owned: Vec<u8> = row.iter().copied().filter(|&l| l != NONE).collect();
|
||||||
|
let changes = owned.windows(2).filter(|p| p[0] != p[1]).count();
|
||||||
|
assert!(changes <= 1, "row {y} changes owner {changes} times");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_seam_goes_round_what_only_one_frame_saw() {
|
||||||
|
// Frame 1 saw something frame 0 did not — a figure that walked into
|
||||||
|
// the overlap — in the middle of where the two meet.
|
||||||
|
let cameras = pair();
|
||||||
|
let figure = Vec3::new(0.175f64.sin(), 0.0, 0.175f64.cos());
|
||||||
|
let walker = |d: Vec3| {
|
||||||
|
let near = (d.x() - figure.x()).abs() < 0.04 && (d.y() - figure.y()).abs() < 0.15;
|
||||||
|
if near {
|
||||||
|
0.95
|
||||||
|
} else {
|
||||||
|
landscape(d)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let frames = [
|
||||||
|
render(&cameras, 0, (320, 240), landscape),
|
||||||
|
render(&cameras, 1, (320, 240), walker),
|
||||||
|
];
|
||||||
|
let refs: Vec<&Gray> = frames.iter().collect();
|
||||||
|
let map = find(
|
||||||
|
&refs,
|
||||||
|
&cameras,
|
||||||
|
&[1.0, 1.0],
|
||||||
|
Projection::Cylindrical,
|
||||||
|
&Default::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Every texel of the figure is taken from the same frame, with a
|
||||||
|
// blend radius of room to spare, so it is either all there or not at
|
||||||
|
// all — never half.
|
||||||
|
let (u, v) = Projection::Cylindrical
|
||||||
|
.from_direction(map.scale, figure)
|
||||||
|
.unwrap();
|
||||||
|
let mut owners = std::collections::HashSet::new();
|
||||||
|
// The figure's extent on the surface, plus the blend's radius.
|
||||||
|
let radius = 3.0;
|
||||||
|
let reach = |half: f64| half * map.scale + radius * map.px;
|
||||||
|
let (ru, rv) = (reach(0.04), reach(0.15));
|
||||||
|
let mut dv = -rv;
|
||||||
|
while dv <= rv {
|
||||||
|
let mut du = -ru;
|
||||||
|
while du <= ru {
|
||||||
|
let s = map.share(1, u + du, v + dv, map.scale, radius);
|
||||||
|
owners.insert((s.unwrap() * 100.0).round() as i32);
|
||||||
|
du += map.px;
|
||||||
|
}
|
||||||
|
dv += map.px;
|
||||||
|
}
|
||||||
|
assert_eq!(owners.len(), 1, "the figure is split: shares {owners:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn share_is_a_blend_across_the_seam_and_whole_away_from_it() {
|
||||||
|
let map = SeamMap {
|
||||||
|
width: 8,
|
||||||
|
height: 1,
|
||||||
|
scale: 1.0,
|
||||||
|
origin: (0.0, 0.0),
|
||||||
|
px: 1.0,
|
||||||
|
labels: vec![0, 0, 0, 0, 1, 1, 1, 1],
|
||||||
|
};
|
||||||
|
assert_eq!(map.share(0, 1.5, 0.5, 1.0, 2.0), Some(1.0));
|
||||||
|
assert_eq!(map.share(1, 6.5, 0.5, 1.0, 2.0), Some(1.0));
|
||||||
|
let at_seam = map.share(0, 4.0, 0.5, 1.0, 2.0).unwrap();
|
||||||
|
assert!((at_seam - 0.5).abs() < 1e-6, "{at_seam}");
|
||||||
|
// And at twice the scale, the same point is twice as far out.
|
||||||
|
assert_eq!(
|
||||||
|
map.share(0, 8.0, 1.0, 2.0, 2.0),
|
||||||
|
map.share(0, 4.0, 0.5, 1.0, 2.0)
|
||||||
|
);
|
||||||
|
let empty = SeamMap {
|
||||||
|
labels: vec![NONE; 8],
|
||||||
|
..map
|
||||||
|
};
|
||||||
|
assert_eq!(empty.share(0, 4.0, 0.5, 1.0, 2.0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
id: camera_profile
|
||||||
|
order: 25
|
||||||
|
# A `rust:` node publishes its own descriptor; its attributes are on the type
|
||||||
|
# in `../src/ops/camera_profile.rs`.
|
||||||
|
rust: CameraProfile
|
||||||
|
|
||||||
|
why_rust: |
|
||||||
|
It reads the source's profile tables from a storage buffer no declaration can
|
||||||
|
name, and it is composed at its defaults — a raw whose profile is on is
|
||||||
|
rendered through it without the photographer having touched anything —
|
||||||
|
which a declared node cannot say (D20).
|
||||||
|
|
||||||
|
placement: |
|
||||||
|
After exposure, before contrast (D20, camera-profiles.md §3). Hue and
|
||||||
|
saturation do not change under the uniform gains before it, so a 2.5-D
|
||||||
|
HueSatMap gives the same answer here as straight after the matrix; and the
|
||||||
|
LookTable sees the exposure the photographer chose, as the DNG SDK's does.
|
||||||
|
Contrast, tone and the colour controls then act on the profiled colour, as
|
||||||
|
they do in Camera Raw.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
drpl 1
|
||||||
|
|
||||||
|
# Vivid: more colour than the default rendering, for the photographer used
|
||||||
|
# to Lightroom's richer starting point (camera-profiles.md §9).
|
||||||
|
#
|
||||||
|
# That difference is mostly Camera Raw's tone curve, not the camera
|
||||||
|
# profile: Adobe Standard's look table, which D20 applies, desaturates dark
|
||||||
|
# tones (camera-profiles.md §1). So these do the work themselves, and work
|
||||||
|
# on every photograph — a JPEG, a body with no profile. They lean on
|
||||||
|
# vibrance before saturation: vibrance lifts muted colours most and holds
|
||||||
|
# skin back, so a frame gets richer before anything in it looks painted.
|
||||||
|
# Saturation, which moves every colour alike, is used sparingly on top.
|
||||||
|
#
|
||||||
|
# Each changes only what it names (FR-DEV-6), so a corrected exposure or
|
||||||
|
# white balance survives applying one.
|
||||||
|
|
||||||
|
[preset Vivid]
|
||||||
|
contrast.contrast = 10
|
||||||
|
saturation.saturation = 8
|
||||||
|
vibrance.vibrance = 30
|
||||||
|
|
||||||
|
[preset Vivid, strong]
|
||||||
|
blacks_whites.blacks = -10
|
||||||
|
clarity.amount = 8
|
||||||
|
contrast.contrast = 18
|
||||||
|
saturation.saturation = 15
|
||||||
|
vibrance.vibrance = 45
|
||||||
|
|
||||||
|
# Foliage and sky: green and chartreuse for leaves and grass, azure and blue
|
||||||
|
# for sky and water, a little yellow for dry grass and stone. The skin bands
|
||||||
|
# — red and orange — are left where they are, so a figure in a landscape
|
||||||
|
# keeps a human complexion.
|
||||||
|
[preset Vivid landscape]
|
||||||
|
clarity.amount = 10
|
||||||
|
colour_mixer.azure_lum = -10
|
||||||
|
colour_mixer.azure_sat = 20
|
||||||
|
colour_mixer.blue_lum = -10
|
||||||
|
colour_mixer.blue_sat = 15
|
||||||
|
colour_mixer.chartreuse_sat = 15
|
||||||
|
colour_mixer.green_sat = 20
|
||||||
|
colour_mixer.yellow_sat = 10
|
||||||
|
contrast.contrast = 12
|
||||||
|
saturation.saturation = 5
|
||||||
|
vibrance.vibrance = 25
|
||||||
|
|
||||||
|
# Golden hour: oranges and yellows up and a warm cast laid over the
|
||||||
|
# highlights only, so shadows stay clean rather than muddy.
|
||||||
|
[preset Vivid warm]
|
||||||
|
colour_grading.highlight_hue = 45
|
||||||
|
colour_grading.highlight_strength = 12
|
||||||
|
colour_mixer.orange_sat = 15
|
||||||
|
colour_mixer.red_sat = 8
|
||||||
|
colour_mixer.yellow_sat = 15
|
||||||
|
contrast.contrast = 8
|
||||||
|
vibrance.vibrance = 25
|
||||||
|
|
||||||
|
# People: everything around the subject gets richer while skin does not.
|
||||||
|
# Vibrance already protects skin; the orange and red bands are then held a
|
||||||
|
# little below where they started, because a face is the one colour every
|
||||||
|
# viewer knows the right value of.
|
||||||
|
[preset Vivid portrait]
|
||||||
|
colour_mixer.azure_sat = 10
|
||||||
|
colour_mixer.blue_sat = 12
|
||||||
|
colour_mixer.green_sat = 12
|
||||||
|
colour_mixer.orange_sat = -10
|
||||||
|
colour_mixer.red_sat = -5
|
||||||
|
contrast.contrast = 6
|
||||||
|
saturation.saturation = -5
|
||||||
|
vibrance.vibrance = 25
|
||||||
@@ -65,6 +65,7 @@ const SECTIONS: &[(&str, &str, &str)] = &[
|
|||||||
include_str!("../presets/essentials.drpl"),
|
include_str!("../presets/essentials.drpl"),
|
||||||
),
|
),
|
||||||
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
||||||
|
("vivid", "Vivid", include_str!("../presets/vivid.drpl")),
|
||||||
(
|
(
|
||||||
"colour_film",
|
"colour_film",
|
||||||
"Film/Colour",
|
"Film/Colour",
|
||||||
|
|||||||
@@ -260,8 +260,9 @@ impl CropRect {
|
|||||||
///
|
///
|
||||||
/// `anchor` is the point of the rect that stays put, in the rect's own
|
/// `anchor` is the point of the rect that stays put, in the rect's own
|
||||||
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
|
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
|
||||||
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
|
/// so the far corner is the one that does not move, `(0.0, 0.5)` while
|
||||||
/// a ratio is chosen and the composition should stay where it is.
|
/// the right-hand edge is dragged, and `(0.5, 0.5)` when a ratio is
|
||||||
|
/// chosen and the composition should stay where it is.
|
||||||
///
|
///
|
||||||
/// **The rect grows onto the ratio rather than shrinking onto it.** The
|
/// **The rect grows onto the ratio rather than shrinking onto it.** The
|
||||||
/// axis that is short is extended; the long one is never trimmed. Fitting
|
/// axis that is short is extended; the long one is never trimmed. Fitting
|
||||||
@@ -269,6 +270,7 @@ impl CropRect {
|
|||||||
/// along one axis alone would be immediately clamped back by the other,
|
/// along one axis alone would be immediately clamped back by the other,
|
||||||
/// and the handle would simply refuse to move. The result is then scaled
|
/// and the handle would simply refuse to move. The result is then scaled
|
||||||
/// down, both axes together, only as far as the frame's edge demands.
|
/// down, both axes together, only as far as the frame's edge demands.
|
||||||
|
/// The exception is an edge: see the note in the body.
|
||||||
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
|
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
|
||||||
let rect = self.normalised();
|
let rect = self.normalised();
|
||||||
let ratio = finite(ratio, 0.0);
|
let ratio = finite(ratio, 0.0);
|
||||||
@@ -286,8 +288,19 @@ impl CropRect {
|
|||||||
let px = rect.x + ax * rect.width;
|
let px = rect.x + ax * rect.width;
|
||||||
let py = rect.y + ay * rect.height;
|
let py = rect.y + ay * rect.height;
|
||||||
|
|
||||||
let mut w = rect.width.max(rect.height * r);
|
// An anchor in the middle of one side is an *edge* being dragged, and
|
||||||
let mut h = w / r;
|
// then the axis across that edge leads: it is the only one the user
|
||||||
|
// moved. Growing the short axis instead would take the other side
|
||||||
|
// for the leader whenever the edge went inward, and the edge would be
|
||||||
|
// pushed straight back out — a handle that only ever grows the crop.
|
||||||
|
let (mut w, mut h) = if ax == 0.5 && ay != 0.5 {
|
||||||
|
(rect.height * r, rect.height)
|
||||||
|
} else if ay == 0.5 && ax != 0.5 {
|
||||||
|
(rect.width, rect.width / r)
|
||||||
|
} else {
|
||||||
|
let w = rect.width.max(rect.height * r);
|
||||||
|
(w, w / r)
|
||||||
|
};
|
||||||
|
|
||||||
// Scaled to fit, never clamped to fit: clamping one axis against the
|
// Scaled to fit, never clamped to fit: clamping one axis against the
|
||||||
// frame would break the very ratio this exists to hold.
|
// frame would break the very ratio this exists to hold.
|
||||||
@@ -2255,6 +2268,42 @@ mod tests {
|
|||||||
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
|
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_locked_edge_leads_and_the_far_side_stays_put() {
|
||||||
|
// An edge dragged inward under a lock must narrow the crop. With the
|
||||||
|
// short axis leading, the untouched height would win and push the
|
||||||
|
// edge straight back out.
|
||||||
|
let start = CropRect {
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.2,
|
||||||
|
width: 0.4,
|
||||||
|
height: 0.6,
|
||||||
|
};
|
||||||
|
// Right edge held, dragged in: the left side and the vertical
|
||||||
|
// centre stay, the width is what was asked for.
|
||||||
|
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5));
|
||||||
|
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
|
||||||
|
assert!((c.width - start.width).abs() < 1e-5, "{c:?}");
|
||||||
|
assert!((c.height - start.width).abs() < 1e-5, "{c:?}");
|
||||||
|
assert!(
|
||||||
|
(c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5,
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top edge held: the bottom and the horizontal centre stay, the
|
||||||
|
// height is what was asked for.
|
||||||
|
let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0));
|
||||||
|
assert!(
|
||||||
|
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
assert!((c.width - c.height).abs() < 1e-5, "{c:?}");
|
||||||
|
assert!(
|
||||||
|
(c.x + c.width / 2.0 - (start.x + start.width / 2.0)).abs() < 1e-5,
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
|
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
|
||||||
// Shrinking to fit makes a one-axis drag do nothing at all: the other
|
// Shrinking to fit makes a one-axis drag do nothing at all: the other
|
||||||
|
|||||||
@@ -1170,19 +1170,21 @@ mod tests {
|
|||||||
// Opening an unedited image must produce the image, not an
|
// Opening an unedited image must produce the image, not an
|
||||||
// interpretation of it.
|
// interpretation of it.
|
||||||
//
|
//
|
||||||
// One block, and it is the view transform: a view operation is
|
// Two blocks, the view transform and the camera profile: both are
|
||||||
// composed at its defaults, because a photograph with no view
|
// composed at their defaults, because a photograph with no view
|
||||||
// transform is a scan rather than a picture (FR-DEV-3j). It is still
|
// transform is a scan rather than a picture (FR-DEV-3j) and a raw
|
||||||
// neutral in the sense that matters here — nothing moved, nothing is
|
// with a profile is rendered through it (D20). They are still neutral
|
||||||
// written — and every adjustment is absent.
|
// in the sense that matters here — nothing moved, nothing is written
|
||||||
|
// — and every adjustment is absent.
|
||||||
let g = EditGraph::default_chain();
|
let g = EditGraph::default_chain();
|
||||||
assert!(g.is_neutral());
|
assert!(g.is_neutral());
|
||||||
let source = g.compose().source;
|
let source = g.compose().source;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
source.matches("---- ").count(),
|
source.matches("---- ").count(),
|
||||||
1,
|
2,
|
||||||
"a neutral graph must generate no adjustment blocks"
|
"a neutral graph must generate no adjustment blocks"
|
||||||
);
|
);
|
||||||
|
assert!(source.contains("---- camera_profile ----"));
|
||||||
assert!(source.contains("---- view_transform ----"));
|
assert!(source.contains("---- view_transform ----"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,13 +1265,14 @@ mod tests {
|
|||||||
fn only_active_operations_reach_the_shader() {
|
fn only_active_operations_reach_the_shader() {
|
||||||
// The composition property, end to end: two adjustments out of seven
|
// The composition property, end to end: two adjustments out of seven
|
||||||
// available must generate a shader doing exactly two things — and
|
// available must generate a shader doing exactly two things — and
|
||||||
// the view transform, which every render has (FR-DEV-3j).
|
// the view transform and camera profile, which every render has
|
||||||
|
// (FR-DEV-3j, D20).
|
||||||
let mut g = EditGraph::default_chain();
|
let mut g = EditGraph::default_chain();
|
||||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||||
|
|
||||||
let shader = g.compose();
|
let shader = g.compose();
|
||||||
assert_eq!(shader.source.matches("---- ").count(), 3);
|
assert_eq!(shader.source.matches("---- ").count(), 4);
|
||||||
assert!(shader.source.contains("---- view_transform ----"));
|
assert!(shader.source.contains("---- view_transform ----"));
|
||||||
assert!(shader.source.contains("---- exposure ----"));
|
assert!(shader.source.contains("---- exposure ----"));
|
||||||
assert!(shader.source.contains("---- white_balance ----"));
|
assert!(shader.source.contains("---- white_balance ----"));
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flipping every switch turns the camera profile *off*, which is
|
||||||
|
// active — moved from the default — and composes nothing. Put it
|
||||||
|
// back on; its look strength stays moved, so it is still active and
|
||||||
|
// now doing something, which is what "fully active" means here (D20).
|
||||||
|
g.set_param(
|
||||||
|
crate::ops::camera_profile::ID,
|
||||||
|
crate::ops::camera_profile::APPLY,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
|
||||||
// `film_sim` is the one node a moved parameter cannot activate: it
|
// `film_sim` is the one node a moved parameter cannot activate: it
|
||||||
// needs a stock's measured tables, which are not parameters and which
|
// needs a stock's measured tables, which are not parameters and which
|
||||||
// no slider produces. So it is loaded explicitly here.
|
// no slider produces. So it is loaded explicitly here.
|
||||||
|
|||||||
@@ -270,6 +270,19 @@ pub trait Operation: Send + Sync {
|
|||||||
/// what is actually used.
|
/// what is actually used.
|
||||||
fn is_active(&self) -> bool;
|
fn is_active(&self) -> bool;
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Whether the composer emits this operation's fragment.
|
||||||
|
///
|
||||||
|
/// Default: exactly when it [`Self::is_active`]. The exception is an
|
||||||
|
/// operation that *is* part of the rendering at its defaults — the
|
||||||
|
/// camera profile, which an untouched raw is rendered through (D20) —
|
||||||
|
/// where "moved from the defaults" and "does something" come apart. Such
|
||||||
|
/// an operation keeps `is_active` meaning the former, so a sidecar still
|
||||||
|
/// stores nothing for it, and answers this with the latter.
|
||||||
|
fn composes(&self) -> bool {
|
||||||
|
self.is_active()
|
||||||
|
}
|
||||||
|
|
||||||
/// The WGSL body of this operation's transform.
|
/// The WGSL body of this operation's transform.
|
||||||
///
|
///
|
||||||
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
|
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
|
||||||
@@ -843,7 +856,7 @@ fn compose_inner(
|
|||||||
let active: Vec<&dyn Operation> = ops
|
let active: Vec<&dyn Operation> = ops
|
||||||
.iter()
|
.iter()
|
||||||
.map(|o| o.as_ref())
|
.map(|o| o.as_ref())
|
||||||
.filter(|o| o.is_active() && o.detail().is_none())
|
.filter(|o| o.composes() && o.detail().is_none())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Whether a detail stage follows. If one does, this pass stops short of
|
// Whether a detail stage follows. If one does, this pass stops short of
|
||||||
@@ -1040,7 +1053,7 @@ fn compose_inner(
|
|||||||
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
||||||
// The global side of the blend. A view operation always has one; see
|
// The global side of the blend. A view operation always has one; see
|
||||||
// `Stage::View`.
|
// `Stage::View`.
|
||||||
let global = op.is_active() || op.stage() == Stage::View;
|
let global = op.composes() || op.stage() == Stage::View;
|
||||||
if !global && local.is_empty() {
|
if !global && local.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1333,6 +1346,11 @@ struct Params {{
|
|||||||
// bound to 1x1 placeholders whenever the flags say not to touch them.
|
// bound to 1x1 placeholders whenever the flags say not to touch them.
|
||||||
@group(0) @binding(6) var sampled: texture_2d<f32>;
|
@group(0) @binding(6) var sampled: texture_2d<f32>;
|
||||||
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
|
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
|
||||||
|
// The source's camera profile tables (FR-DEV-3e, D20): a two-entry header,
|
||||||
|
// then the entries (`ops::camera_profile::profile_buffer`). Declared
|
||||||
|
// unconditionally like the masks, and bound to a header of zeros — no
|
||||||
|
// tables — for every source without a profile.
|
||||||
|
@group(0) @binding(8) var<storage, read> profile_table: array<vec4<f32>>;
|
||||||
|
|
||||||
{WINDOW_HELPER}{sampler_helper}{helper_src}{encode_output}
|
{WINDOW_HELPER}{sampler_helper}{helper_src}{encode_output}
|
||||||
// Display-encoded sRGB back to linear, for sources that arrive that way.
|
// Display-encoded sRGB back to linear, for sources that arrive that way.
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
//! TRACES: FR-DEV-3e
|
||||||
|
//! The camera profile's tables as an operation (D20).
|
||||||
|
//!
|
||||||
|
//! The matrix turns camera RGB into colour; a DNG camera profile adds two
|
||||||
|
//! lookups over hue, saturation and value on top of it — the `HueSatMap`, a
|
||||||
|
//! calibration, and the `LookTable`, a rendering intent. This operation
|
||||||
|
//! applies them. `docs/dev/camera-profiles.md` is the design.
|
||||||
|
//!
|
||||||
|
//! # Where the tables come from
|
||||||
|
//!
|
||||||
|
//! Not from here. They belong to the *source*, like the matrix: `dr-decode`
|
||||||
|
//! resolves them per file and `dr-gpu` uploads them to the storage buffer
|
||||||
|
//! every generated shader declares at `@binding(8)`, laid out by
|
||||||
|
//! [`profile_buffer`]. This operation holds only the photographer's two
|
||||||
|
//! settings — whether to use the profile, and how strongly to apply its look
|
||||||
|
//! — so a render path never has to remember to hand it anything.
|
||||||
|
//!
|
||||||
|
//! # Why it is composed at its defaults
|
||||||
|
//!
|
||||||
|
//! A profile that is on is the rendering, not an edit: an untouched raw
|
||||||
|
//! renders through it and writes no parameters. So [`Operation::composes`]
|
||||||
|
//! answers "is the switch on", not "has anything moved". The fragment then
|
||||||
|
//! branches on the buffer's header, which says whether this source has tables
|
||||||
|
//! at all; a JPEG, or a raw with no profile, reads two zeros and passes
|
||||||
|
//! through.
|
||||||
|
//!
|
||||||
|
//! # The lookup
|
||||||
|
//!
|
||||||
|
//! The DNG SDK's `RefBaselineHueSatMap`, with the two departures §2 of the
|
||||||
|
//! design gives for scene-referred values: value is not clamped on the way
|
||||||
|
//! out, and a colour with a negative ProPhoto component passes through.
|
||||||
|
//! [`apply_reference`] is the same arithmetic on the CPU, and the GPU tests
|
||||||
|
//! hold the shader to it.
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use dr_types::{HueSatTable, ProfileTables};
|
||||||
|
|
||||||
|
use crate::descriptor::{
|
||||||
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
|
};
|
||||||
|
use crate::operation::{Helper, Operation, Uniform};
|
||||||
|
|
||||||
|
pub const ID: OpId = OpId("camera_profile");
|
||||||
|
pub const APPLY: ParamId = ParamId("apply");
|
||||||
|
pub const LOOK: ParamId = ParamId("look");
|
||||||
|
|
||||||
|
/// The look's strength at which the LookTable is applied as the profile
|
||||||
|
/// states it, in percent.
|
||||||
|
pub const DEFAULT_LOOK: f32 = 100.0;
|
||||||
|
/// Twice the profile's look: Lightroom's *Amount* reaches the same.
|
||||||
|
pub const MAX_LOOK: f32 = 200.0;
|
||||||
|
|
||||||
|
/// Entries of the buffer's header: one `vec4` describing each table —
|
||||||
|
/// `(hue divisions, saturation divisions, value divisions, sRGB-encoded)`,
|
||||||
|
/// zero hue divisions meaning absent — before the entries themselves.
|
||||||
|
pub const HEADER_ENTRIES: usize = 2;
|
||||||
|
|
||||||
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
|
Arc::new(OpDescriptor {
|
||||||
|
attributes: vec![Attribute::Colour],
|
||||||
|
id: ID,
|
||||||
|
label: LocalizedKey("op.camera_profile"),
|
||||||
|
params: vec![
|
||||||
|
ParamDescriptor::switch_on("apply", "param.camera_profile.apply"),
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"look",
|
||||||
|
"param.camera_profile.look",
|
||||||
|
0.0,
|
||||||
|
MAX_LOOK,
|
||||||
|
DEFAULT_LOOK,
|
||||||
|
Unit::Percent,
|
||||||
|
Scale::Linear,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
|
||||||
|
/// each row scaled to sum to one so that working white is ProPhoto white
|
||||||
|
/// exactly and a neutral reaches the tables with zero saturation.
|
||||||
|
fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
|
||||||
|
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
|
||||||
|
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
|
||||||
|
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
|
||||||
|
(to, back)
|
||||||
|
});
|
||||||
|
&M
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalise_rows(mut m: [f32; 9]) -> [f32; 9] {
|
||||||
|
for row in m.chunks_exact_mut(3) {
|
||||||
|
let sum: f32 = row.iter().sum();
|
||||||
|
row.iter_mut().for_each(|v| *v /= sum);
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
|
||||||
|
let [a, b, c, d, e, f, g, h, i] = m.map(f64::from);
|
||||||
|
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||||
|
if det.abs() < 1e-12 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let inv = [
|
||||||
|
(e * i - f * h) / det,
|
||||||
|
(c * h - b * i) / det,
|
||||||
|
(b * f - c * e) / det,
|
||||||
|
(f * g - d * i) / det,
|
||||||
|
(a * i - c * g) / det,
|
||||||
|
(c * d - a * f) / det,
|
||||||
|
(d * h - e * g) / det,
|
||||||
|
(b * g - a * h) / det,
|
||||||
|
(a * e - b * d) / det,
|
||||||
|
];
|
||||||
|
Some(inv.map(|v| v as f32))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
|
||||||
|
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row-major matrix as a WGSL `mat3x3`, whose constructor takes columns.
|
||||||
|
fn wgsl_mat(m: &[f32; 9]) -> String {
|
||||||
|
let col = |j: usize| format!("vec3<f32>({:e}, {:e}, {:e})", m[j], m[3 + j], m[6 + j]);
|
||||||
|
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
static HELPERS: LazyLock<[Helper; 1]> = LazyLock::new(|| {
|
||||||
|
let (to, back) = working_prophoto();
|
||||||
|
let source = format!(
|
||||||
|
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{LOOKUP_WGSL}",
|
||||||
|
wgsl_mat(to),
|
||||||
|
wgsl_mat(back)
|
||||||
|
);
|
||||||
|
[Helper {
|
||||||
|
name: "profile_apply",
|
||||||
|
source: Box::leak(source.into_boxed_str()),
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
|
||||||
|
const LOOKUP_WGSL: &str = r#"
|
||||||
|
fn profile_srgb_encode(v: f32) -> f32 {
|
||||||
|
if (v <= 0.0031308) { return v * 12.92; }
|
||||||
|
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_srgb_decode(v: f32) -> f32 {
|
||||||
|
if (v <= 0.04045) { return v / 12.92; }
|
||||||
|
return pow((v + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DNG SDK's HSV: hue in [0, 6), saturation (max - min) / max, value max.
|
||||||
|
fn profile_rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
|
||||||
|
let v = max(c.r, max(c.g, c.b));
|
||||||
|
let gap = v - min(c.r, min(c.g, c.b));
|
||||||
|
if (gap <= 0.0) {
|
||||||
|
return vec3<f32>(0.0, 0.0, v);
|
||||||
|
}
|
||||||
|
var h: f32;
|
||||||
|
if (c.r == v) {
|
||||||
|
h = (c.g - c.b) / gap;
|
||||||
|
if (h < 0.0) { h += 6.0; }
|
||||||
|
} else if (c.g == v) {
|
||||||
|
h = 2.0 + (c.b - c.r) / gap;
|
||||||
|
} else {
|
||||||
|
h = 4.0 + (c.r - c.g) / gap;
|
||||||
|
}
|
||||||
|
return vec3<f32>(h, gap / v, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
|
||||||
|
let s = hsv.y;
|
||||||
|
let v = hsv.z;
|
||||||
|
if (s <= 0.0) {
|
||||||
|
return vec3<f32>(v);
|
||||||
|
}
|
||||||
|
let h = hsv.x - 6.0 * floor(hsv.x / 6.0);
|
||||||
|
let i = min(floor(h), 5.0);
|
||||||
|
let f = h - i;
|
||||||
|
let p = v * (1.0 - s);
|
||||||
|
let q = v * (1.0 - s * f);
|
||||||
|
let t = v * (1.0 - s * (1.0 - f));
|
||||||
|
switch (i32(i)) {
|
||||||
|
case 0: { return vec3<f32>(v, t, p); }
|
||||||
|
case 1: { return vec3<f32>(q, v, p); }
|
||||||
|
case 2: { return vec3<f32>(p, v, t); }
|
||||||
|
case 3: { return vec3<f32>(p, q, v); }
|
||||||
|
case 4: { return vec3<f32>(t, p, v); }
|
||||||
|
default: { return vec3<f32>(v, p, q); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_entry(base: u32, at: u32) -> vec3<f32> {
|
||||||
|
return profile_table[base + at].xyz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (hue shift in degrees, saturation scale, value scale) at `hsv`: bilinear
|
||||||
|
// over hue and saturation, hue wrapping, and linear over value for a 3-D
|
||||||
|
// table. Indices are the SDK's.
|
||||||
|
fn profile_lookup(dims: vec4<f32>, base: u32, hsv: vec3<f32>) -> vec3<f32> {
|
||||||
|
let hd = u32(dims.x);
|
||||||
|
let sd = u32(dims.y);
|
||||||
|
let vd = u32(dims.z);
|
||||||
|
|
||||||
|
var h0 = 0u;
|
||||||
|
var h1 = 0u;
|
||||||
|
var hf = 0.0;
|
||||||
|
if (hd > 1u) {
|
||||||
|
let hs = hsv.x * f32(hd) / 6.0;
|
||||||
|
h0 = min(u32(hs), hd - 1u);
|
||||||
|
hf = hs - f32(h0);
|
||||||
|
h1 = h0 + 1u;
|
||||||
|
if (h1 >= hd) { h1 = 0u; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let ss = hsv.y * f32(sd - 1u);
|
||||||
|
let s0 = min(u32(ss), sd - 2u);
|
||||||
|
let sf = ss - f32(s0);
|
||||||
|
|
||||||
|
var v0 = 0u;
|
||||||
|
var vf = 0.0;
|
||||||
|
if (vd > 1u) {
|
||||||
|
var ve = clamp(hsv.z, 0.0, 1.0);
|
||||||
|
if (dims.w > 0.5) { ve = profile_srgb_encode(ve); }
|
||||||
|
let vs = ve * f32(vd - 1u);
|
||||||
|
v0 = min(u32(vs), vd - 2u);
|
||||||
|
vf = vs - f32(v0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let val_step = hd * sd;
|
||||||
|
let lo = v0 * val_step;
|
||||||
|
var d = mix(
|
||||||
|
mix(profile_entry(base, lo + h0 * sd + s0), profile_entry(base, lo + h1 * sd + s0), hf),
|
||||||
|
mix(profile_entry(base, lo + h0 * sd + s0 + 1u), profile_entry(base, lo + h1 * sd + s0 + 1u), hf),
|
||||||
|
sf);
|
||||||
|
if (vd > 1u) {
|
||||||
|
let hi = lo + val_step;
|
||||||
|
let e = mix(
|
||||||
|
mix(profile_entry(base, hi + h0 * sd + s0), profile_entry(base, hi + h1 * sd + s0), hf),
|
||||||
|
mix(profile_entry(base, hi + h0 * sd + s0 + 1u), profile_entry(base, hi + h1 * sd + s0 + 1u), hf),
|
||||||
|
sf);
|
||||||
|
d = mix(d, e, vf);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One table applied to a ProPhoto colour, its deltas scaled by `amount`.
|
||||||
|
fn profile_apply(dims: vec4<f32>, base: u32, c: vec3<f32>, amount: f32) -> vec3<f32> {
|
||||||
|
let hsv = profile_rgb_to_hsv(c);
|
||||||
|
var d = profile_lookup(dims, base, hsv);
|
||||||
|
d = vec3<f32>(d.x * amount, max(1.0 + (d.y - 1.0) * amount, 0.0), max(1.0 + (d.z - 1.0) * amount, 0.0));
|
||||||
|
let h = hsv.x + d.x * (6.0 / 360.0);
|
||||||
|
let s = min(hsv.y * d.y, 1.0);
|
||||||
|
var v = hsv.z * d.z;
|
||||||
|
if (dims.w > 0.5) {
|
||||||
|
// The scale is defined on the encoded value; applied as the ratio it
|
||||||
|
// makes at min(v, 1), so a value above 1.0 is scaled, not clipped.
|
||||||
|
let vc = min(hsv.z, 1.0);
|
||||||
|
v = hsv.z;
|
||||||
|
if (vc > 0.0) {
|
||||||
|
v = hsv.z * profile_srgb_decode(profile_srgb_encode(vc) * d.z) / vc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profile_hsv_to_rgb(vec3<f32>(h, s, v));
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CameraProfile {
|
||||||
|
apply: bool,
|
||||||
|
look: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CameraProfile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
apply: true,
|
||||||
|
look: DEFAULT_LOOK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CameraProfile {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation for CameraProfile {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
DESCRIPTOR.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
match id {
|
||||||
|
APPLY => self.apply = value != 0.0,
|
||||||
|
LOOK => self.look = value,
|
||||||
|
_ => log::warn!("camera_profile: unknown parameter {id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, id: ParamId) -> f32 {
|
||||||
|
match id {
|
||||||
|
APPLY => f32::from(u8::from(self.apply)),
|
||||||
|
LOOK => self.look,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
!self.apply || self.look != DEFAULT_LOOK
|
||||||
|
}
|
||||||
|
|
||||||
|
fn composes(&self) -> bool {
|
||||||
|
self.apply
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
"\
|
||||||
|
let hue_sat_dims = profile_table[0];
|
||||||
|
let look_dims = profile_table[1];
|
||||||
|
if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
|
||||||
|
var p = PROFILE_FROM_WORKING * c;
|
||||||
|
// A colour outside ProPhoto has no HSV the tables were made for; it
|
||||||
|
// passes through rather than being floored, which would clip it (D19).
|
||||||
|
if (min(p.r, min(p.g, p.b)) >= 0.0) {
|
||||||
|
let look_base = 2u + u32(hue_sat_dims.x * hue_sat_dims.y * hue_sat_dims.z);
|
||||||
|
if (hue_sat_dims.x > 0.0) {
|
||||||
|
p = profile_apply(hue_sat_dims, 2u, p, 1.0);
|
||||||
|
}
|
||||||
|
if (look_dims.x > 0.0 && look > 0.0) {
|
||||||
|
p = profile_apply(look_dims, look_base, p, look);
|
||||||
|
}
|
||||||
|
c = PROFILE_TO_WORKING * p;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
vec![Uniform {
|
||||||
|
name: "look",
|
||||||
|
value: self.look / 100.0,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helpers(&self) -> &[Helper] {
|
||||||
|
HELPERS.as_slice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The storage buffer a source's tables are uploaded as: the two header
|
||||||
|
/// `vec4`s, the HueSatMap's entries, then the LookTable's, each entry
|
||||||
|
/// `(hue shift, saturation scale, value scale, 0)`.
|
||||||
|
///
|
||||||
|
/// Two zero `vec4`s where there are no tables — the placeholder every source
|
||||||
|
/// without a profile binds, and what makes the fragment pass through.
|
||||||
|
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
|
||||||
|
let header = |t: Option<&HueSatTable>| match t {
|
||||||
|
Some(t) => [
|
||||||
|
t.hue_divisions as f32,
|
||||||
|
t.sat_divisions as f32,
|
||||||
|
t.val_divisions as f32,
|
||||||
|
if t.srgb_encoded { 1.0 } else { 0.0 },
|
||||||
|
],
|
||||||
|
None => [0.0; 4],
|
||||||
|
};
|
||||||
|
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
|
||||||
|
let look = tables.and_then(|t| t.look.as_ref());
|
||||||
|
let mut out = vec![header(hue_sat), header(look)];
|
||||||
|
for t in [hue_sat, look].into_iter().flatten() {
|
||||||
|
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The fragment's arithmetic on the CPU: a working-space colour through the
|
||||||
|
/// source's tables, the look at `look` (1.0 = as the profile states it).
|
||||||
|
///
|
||||||
|
/// The reference the shader is tested against, and the statement of the
|
||||||
|
/// algorithm a reader can step through.
|
||||||
|
pub fn apply_reference(tables: &ProfileTables, c: [f32; 3], look: f32) -> [f32; 3] {
|
||||||
|
let (to, back) = working_prophoto();
|
||||||
|
let mut p = mul(to, c);
|
||||||
|
if p.iter().any(|v| *v < 0.0) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if let Some(t) = &tables.hue_sat {
|
||||||
|
p = apply_table(t, p, 1.0);
|
||||||
|
}
|
||||||
|
if let Some(t) = tables.look.as_ref().filter(|_| look > 0.0) {
|
||||||
|
p = apply_table(t, p, look);
|
||||||
|
}
|
||||||
|
mul(back, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn srgb_encode(v: f32) -> f32 {
|
||||||
|
if v <= 0.003_130_8 {
|
||||||
|
v * 12.92
|
||||||
|
} else {
|
||||||
|
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn srgb_decode(v: f32) -> f32 {
|
||||||
|
if v <= 0.040_45 {
|
||||||
|
v / 12.92
|
||||||
|
} else {
|
||||||
|
((v + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SDK's `DNG_RGBtoHSV`: hue in `[0, 6)`.
|
||||||
|
pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||||
|
let v = r.max(g).max(b);
|
||||||
|
let gap = v - r.min(g).min(b);
|
||||||
|
if gap <= 0.0 {
|
||||||
|
return [0.0, 0.0, v];
|
||||||
|
}
|
||||||
|
let h = if r == v {
|
||||||
|
let h = (g - b) / gap;
|
||||||
|
if h < 0.0 {
|
||||||
|
h + 6.0
|
||||||
|
} else {
|
||||||
|
h
|
||||||
|
}
|
||||||
|
} else if g == v {
|
||||||
|
2.0 + (b - r) / gap
|
||||||
|
} else {
|
||||||
|
4.0 + (r - g) / gap
|
||||||
|
};
|
||||||
|
[h, gap / v, v]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||||
|
if s <= 0.0 {
|
||||||
|
return [v; 3];
|
||||||
|
}
|
||||||
|
let h = h - 6.0 * (h / 6.0).floor();
|
||||||
|
let i = h.floor().min(5.0);
|
||||||
|
let f = h - i;
|
||||||
|
let p = v * (1.0 - s);
|
||||||
|
let q = v * (1.0 - s * f);
|
||||||
|
let t = v * (1.0 - s * (1.0 - f));
|
||||||
|
match i as i32 {
|
||||||
|
0 => [v, t, p],
|
||||||
|
1 => [q, v, p],
|
||||||
|
2 => [p, v, t],
|
||||||
|
3 => [p, q, v],
|
||||||
|
4 => [t, p, v],
|
||||||
|
_ => [v, p, q],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(t: &HueSatTable, [h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||||
|
let (hd, sd, vd) = (t.hue_divisions, t.sat_divisions, t.val_divisions);
|
||||||
|
let (mut h0, mut h1, mut hf) = (0u32, 0u32, 0.0f32);
|
||||||
|
if hd > 1 {
|
||||||
|
let hs = h * hd as f32 / 6.0;
|
||||||
|
h0 = (hs as u32).min(hd - 1);
|
||||||
|
hf = hs - h0 as f32;
|
||||||
|
h1 = if h0 + 1 >= hd { 0 } else { h0 + 1 };
|
||||||
|
}
|
||||||
|
let ss = s * (sd - 1) as f32;
|
||||||
|
let s0 = (ss as u32).min(sd - 2);
|
||||||
|
let sf = ss - s0 as f32;
|
||||||
|
let (mut v0, mut vf) = (0u32, 0.0f32);
|
||||||
|
if vd > 1 {
|
||||||
|
let mut ve = v.clamp(0.0, 1.0);
|
||||||
|
if t.srgb_encoded {
|
||||||
|
ve = srgb_encode(ve);
|
||||||
|
}
|
||||||
|
let vs = ve * (vd - 1) as f32;
|
||||||
|
v0 = (vs as u32).min(vd - 2);
|
||||||
|
vf = vs - v0 as f32;
|
||||||
|
}
|
||||||
|
let mix = |a: [f32; 3], b: [f32; 3], w: f32| -> [f32; 3] {
|
||||||
|
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * w)
|
||||||
|
};
|
||||||
|
let at = |v: u32, h: u32, s: u32| t.entries[t.index(h, s, v)];
|
||||||
|
let plane = |v: u32| {
|
||||||
|
mix(
|
||||||
|
mix(at(v, h0, s0), at(v, h1, s0), hf),
|
||||||
|
mix(at(v, h0, s0 + 1), at(v, h1, s0 + 1), hf),
|
||||||
|
sf,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let d = plane(v0);
|
||||||
|
if vd > 1 {
|
||||||
|
mix(d, plane(v0 + 1), vf)
|
||||||
|
} else {
|
||||||
|
d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_table(t: &HueSatTable, c: [f32; 3], amount: f32) -> [f32; 3] {
|
||||||
|
let hsv = rgb_to_hsv(c);
|
||||||
|
let d = lookup(t, hsv);
|
||||||
|
let d = [
|
||||||
|
d[0] * amount,
|
||||||
|
(1.0 + (d[1] - 1.0) * amount).max(0.0),
|
||||||
|
(1.0 + (d[2] - 1.0) * amount).max(0.0),
|
||||||
|
];
|
||||||
|
let h = hsv[0] + d[0] * (6.0 / 360.0);
|
||||||
|
let s = (hsv[1] * d[1]).min(1.0);
|
||||||
|
let v = if t.srgb_encoded {
|
||||||
|
let vc = hsv[2].min(1.0);
|
||||||
|
if vc > 0.0 {
|
||||||
|
hsv[2] * srgb_decode(srgb_encode(vc) * d[2]) / vc
|
||||||
|
} else {
|
||||||
|
hsv[2]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hsv[2] * d[2]
|
||||||
|
};
|
||||||
|
hsv_to_rgb([h, s, v])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use dr_types::ProfileOrigin;
|
||||||
|
|
||||||
|
fn uniform(h: u32, s: u32, v: u32, e: [f32; 3]) -> HueSatTable {
|
||||||
|
HueSatTable::new(h, s, v, false, vec![e; (h * s * v) as usize]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tables(hue_sat: Option<HueSatTable>, look: Option<HueSatTable>) -> ProfileTables {
|
||||||
|
ProfileTables {
|
||||||
|
name: "test".into(),
|
||||||
|
origin: ProfileOrigin::Embedded,
|
||||||
|
hue_sat,
|
||||||
|
look,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||||
|
a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.all(|(x, y)| (x - y).abs() <= tol * y.abs().max(1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_starts_neutral_and_composed() {
|
||||||
|
let op = CameraProfile::new();
|
||||||
|
assert!(!op.is_active(), "an untouched photograph writes nothing");
|
||||||
|
assert!(op.composes(), "and still renders through its profile");
|
||||||
|
let mut off = CameraProfile::new();
|
||||||
|
off.set_param(APPLY, 0.0);
|
||||||
|
assert!(off.is_active() && !off.composes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_working_space_round_trips_through_prophoto() {
|
||||||
|
let (to, back) = working_prophoto();
|
||||||
|
for c in [[1.0, 1.0, 1.0], [0.2, 0.5, 0.1], [4.0, 0.3, 0.02]] {
|
||||||
|
assert!(close(mul(back, mul(to, c)), c, 1e-5), "{c:?}");
|
||||||
|
}
|
||||||
|
let white = mul(to, [1.0; 3]);
|
||||||
|
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-6), "{white:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hsv_round_trips() {
|
||||||
|
for c in [
|
||||||
|
[0.9, 0.2, 0.1],
|
||||||
|
[0.1, 0.7, 0.3],
|
||||||
|
[0.2, 0.3, 0.8],
|
||||||
|
[0.5, 0.5, 0.5],
|
||||||
|
[3.0, 1.0, 2.0],
|
||||||
|
] {
|
||||||
|
assert!(close(hsv_to_rgb(rgb_to_hsv(c)), c, 1e-6), "{c:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grey_passes_through() {
|
||||||
|
let t = tables(
|
||||||
|
Some(uniform(6, 3, 1, [30.0, 1.5, 1.0])),
|
||||||
|
Some(uniform(6, 3, 1, [-20.0, 1.3, 1.0])),
|
||||||
|
);
|
||||||
|
for v in [0.0, 0.18, 1.0, 8.0] {
|
||||||
|
let out = apply_reference(&t, [v; 3], 1.0);
|
||||||
|
assert!(close(out, [v; 3], 1e-5), "{v}: {out:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_identity_table_changes_nothing() {
|
||||||
|
let t = tables(
|
||||||
|
Some(uniform(90, 30, 1, [0.0, 1.0, 1.0])),
|
||||||
|
Some(uniform(36, 8, 16, [0.0, 1.0, 1.0])),
|
||||||
|
);
|
||||||
|
for c in [[0.9, 0.2, 0.1], [0.05, 0.4, 0.2], [2.0, 0.5, 0.3]] {
|
||||||
|
assert!(close(apply_reference(&t, c, 1.0), c, 1e-5), "{c:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saturation_scale_scales_saturation() {
|
||||||
|
let t = tables(Some(uniform(6, 3, 1, [0.0, 1.2, 1.0])), None);
|
||||||
|
let (to, _) = working_prophoto();
|
||||||
|
let c = [0.6, 0.3, 0.2];
|
||||||
|
let before = rgb_to_hsv(mul(to, c));
|
||||||
|
let after = rgb_to_hsv(mul(to, apply_reference(&t, c, 1.0)));
|
||||||
|
assert!(
|
||||||
|
(after[1] - before[1] * 1.2).abs() < 1e-4,
|
||||||
|
"{before:?} {after:?}"
|
||||||
|
);
|
||||||
|
assert!((after[0] - before[0]).abs() < 1e-4);
|
||||||
|
assert!((after[2] - before[2]).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hue_interpolation_wraps_from_the_last_column_to_the_first() {
|
||||||
|
// Four hue columns: a shift only in the first. A hue just short of
|
||||||
|
// 6.0 (red, from the magenta side) sits between the last column and
|
||||||
|
// the first, and must take most of the first's shift.
|
||||||
|
let mut e = vec![[0.0, 1.0, 1.0]; 4 * 2];
|
||||||
|
e[0] = [40.0, 1.0, 1.0];
|
||||||
|
e[1] = [40.0, 1.0, 1.0];
|
||||||
|
let t = HueSatTable::new(4, 2, 1, false, e).unwrap();
|
||||||
|
let d = lookup(&t, [5.9, 0.5, 0.5]);
|
||||||
|
assert!(d[0] > 30.0, "{d:?}");
|
||||||
|
// Columns sit at hue 0, 1.5, 3 and 4.5; between the third and the
|
||||||
|
// fourth, neither of which shifts, nothing moves.
|
||||||
|
let d = lookup(&t, [3.7, 0.5, 0.5]);
|
||||||
|
assert!(d[0].abs() < 1e-6, "{d:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_value_above_one_stays_above_one() {
|
||||||
|
let t = tables(None, Some(uniform(6, 3, 4, [5.0, 1.1, 0.9])));
|
||||||
|
let out = apply_reference(&t, [6.0, 3.0, 2.0], 1.0);
|
||||||
|
assert!(out.iter().any(|v| *v > 1.0), "{out:?}");
|
||||||
|
let mut srgb = uniform(6, 3, 4, [0.0, 1.0, 0.9]);
|
||||||
|
srgb.srgb_encoded = true;
|
||||||
|
let out = apply_reference(&tables(None, Some(srgb)), [6.0, 3.0, 2.0], 1.0);
|
||||||
|
assert!(out.iter().all(|v| v.is_finite()) && out[0] > 1.0, "{out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_look_strength_scales_the_look_alone() {
|
||||||
|
let hs = uniform(6, 3, 1, [0.0, 1.1, 1.0]);
|
||||||
|
let look = uniform(6, 3, 1, [0.0, 1.2, 1.0]);
|
||||||
|
let t = tables(Some(hs.clone()), Some(look));
|
||||||
|
let c = [0.5, 0.3, 0.2];
|
||||||
|
let none = apply_reference(&t, c, 0.0);
|
||||||
|
assert!(close(
|
||||||
|
none,
|
||||||
|
apply_reference(&tables(Some(hs), None), c, 1.0),
|
||||||
|
1e-6
|
||||||
|
));
|
||||||
|
let (to, _) = working_prophoto();
|
||||||
|
let s = |x| rgb_to_hsv(mul(to, x))[1];
|
||||||
|
assert!(s(apply_reference(&t, c, 2.0)) > s(apply_reference(&t, c, 1.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
|
||||||
|
assert_eq!(profile_buffer(None), vec![[0.0; 4]; 2]);
|
||||||
|
let t = tables(
|
||||||
|
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
|
||||||
|
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
|
||||||
|
);
|
||||||
|
let b = profile_buffer(Some(&t));
|
||||||
|
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
|
||||||
|
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
|
||||||
|
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12);
|
||||||
|
assert_eq!(b[2], [1.0, 2.0, 3.0, 0.0]);
|
||||||
|
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
|
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
|
||||||
// places it in the chain; these are the implementations that entry points at.
|
// places it in the chain; these are the implementations that entry points at.
|
||||||
pub mod aberration;
|
pub mod aberration;
|
||||||
|
pub mod camera_profile;
|
||||||
pub mod capture_sharpen;
|
pub mod capture_sharpen;
|
||||||
pub mod colour_mixer;
|
pub mod colour_mixer;
|
||||||
pub mod curve;
|
pub mod curve;
|
||||||
@@ -78,6 +79,7 @@ pub mod view_transform;
|
|||||||
pub mod vignetting;
|
pub mod vignetting;
|
||||||
|
|
||||||
pub use aberration::Aberration;
|
pub use aberration::Aberration;
|
||||||
|
pub use camera_profile::CameraProfile;
|
||||||
pub use capture_sharpen::CaptureSharpen;
|
pub use capture_sharpen::CaptureSharpen;
|
||||||
pub use colour_mixer::ColourMixer;
|
pub use colour_mixer::ColourMixer;
|
||||||
pub use curve::ToneCurve;
|
pub use curve::ToneCurve;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
//!
|
//!
|
||||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
|
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
|
||||||
//! `view_transform` —
|
//! `view_transform`, `camera_profile` —
|
||||||
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
||||||
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||||
//! `ops/` between them, so a node that stops being declared cannot quietly
|
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||||
@@ -396,6 +396,7 @@ fn every_declared_node_is_checked() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
hand,
|
hand,
|
||||||
[
|
[
|
||||||
|
"camera_profile",
|
||||||
"capture_sharpen",
|
"capture_sharpen",
|
||||||
"clarity",
|
"clarity",
|
||||||
"colour_mixer",
|
"colour_mixer",
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! TRACES: FR-DEV-3e
|
||||||
|
//! A camera profile's hue/saturation/value tables, as the renderer receives
|
||||||
|
//! them.
|
||||||
|
//!
|
||||||
|
//! # Why this lives in the types crate
|
||||||
|
//!
|
||||||
|
//! Three crates handle these and none depends on the next: `dr-decode` reads
|
||||||
|
//! them out of a DNG or a `.dcp`, `dr-pipeline` emits the shader that indexes
|
||||||
|
//! them, and `dr-gpu` uploads them in between. The layout — saturation
|
||||||
|
//! fastest, then hue, then value — is the one fact all three must agree on, so
|
||||||
|
//! it is stated once, here, by [`HueSatTable::index`].
|
||||||
|
//!
|
||||||
|
//! See `docs/dev/camera-profiles.md` for the model and D20 for where the
|
||||||
|
//! tables run.
|
||||||
|
|
||||||
|
/// One `ProfileHueSatMap` or `ProfileLookTable`: a grid over HSV whose every
|
||||||
|
/// entry is `(hue shift in degrees, saturation scale, value scale)`.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct HueSatTable {
|
||||||
|
pub hue_divisions: u32,
|
||||||
|
pub sat_divisions: u32,
|
||||||
|
/// 1 for a "2.5-D" table, which ignores value.
|
||||||
|
pub val_divisions: u32,
|
||||||
|
/// The value axis is indexed by the sRGB-encoded value rather than the
|
||||||
|
/// linear one (`ProfileHueSatMapEncoding` / `ProfileLookTableEncoding`
|
||||||
|
/// = 1).
|
||||||
|
pub srgb_encoded: bool,
|
||||||
|
/// `hue_divisions × sat_divisions × val_divisions` entries, in
|
||||||
|
/// [`Self::index`] order.
|
||||||
|
pub entries: Vec<[f32; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HueSatTable {
|
||||||
|
/// Build a table, refusing one whose shape cannot be indexed.
|
||||||
|
///
|
||||||
|
/// Saturation needs two samples to interpolate between, and a table with
|
||||||
|
/// a zero dimension or the wrong number of entries is a file that lies
|
||||||
|
/// about itself; either is `None` rather than a lookup that reads past
|
||||||
|
/// its end.
|
||||||
|
pub fn new(
|
||||||
|
hue_divisions: u32,
|
||||||
|
sat_divisions: u32,
|
||||||
|
val_divisions: u32,
|
||||||
|
srgb_encoded: bool,
|
||||||
|
entries: Vec<[f32; 3]>,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let count = (hue_divisions as usize)
|
||||||
|
.checked_mul(sat_divisions as usize)?
|
||||||
|
.checked_mul(val_divisions as usize)?;
|
||||||
|
let sane = hue_divisions >= 1
|
||||||
|
&& sat_divisions >= 2
|
||||||
|
&& val_divisions >= 1
|
||||||
|
&& entries.len() == count
|
||||||
|
// Large enough for any real profile (Adobe's largest are
|
||||||
|
// 90×30×1 and 36×8×16); small enough that a corrupt dimension
|
||||||
|
// cannot ask the GPU for gigabytes.
|
||||||
|
&& count <= 1 << 20
|
||||||
|
&& entries.iter().flatten().all(|v| v.is_finite());
|
||||||
|
sane.then_some(Self {
|
||||||
|
hue_divisions,
|
||||||
|
sat_divisions,
|
||||||
|
val_divisions,
|
||||||
|
srgb_encoded,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the entry for `(hue, sat, val)` sits: saturation fastest, then
|
||||||
|
/// hue, then value, as the DNG specification stores it.
|
||||||
|
pub fn index(&self, hue: u32, sat: u32, val: u32) -> usize {
|
||||||
|
((val * self.hue_divisions + hue) * self.sat_divisions + sat) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry-by-entry blend toward `other`, for a two-illuminant HueSatMap.
|
||||||
|
///
|
||||||
|
/// `None` where the two are not the same shape, which a well-formed
|
||||||
|
/// profile never produces — both data tags share one dimensions tag.
|
||||||
|
pub fn lerp(&self, other: &Self, t: f32) -> Option<Self> {
|
||||||
|
if (self.hue_divisions, self.sat_divisions, self.val_divisions)
|
||||||
|
!= (
|
||||||
|
other.hue_divisions,
|
||||||
|
other.sat_divisions,
|
||||||
|
other.val_divisions,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let entries = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.zip(&other.entries)
|
||||||
|
.map(|(a, b)| std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t))
|
||||||
|
.collect();
|
||||||
|
Some(Self {
|
||||||
|
entries,
|
||||||
|
..self.clone()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether every entry is `(0°, 1, 1)`, so the table changes nothing.
|
||||||
|
pub fn is_identity(&self) -> bool {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.all(|e| e[0] == 0.0 && e[1] == 1.0 && e[2] == 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a source hands the renderer: the tables already resolved for this
|
||||||
|
/// frame, the HueSatMap blended for the light it was shot under.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProfileTables {
|
||||||
|
/// The profile's name, for the panel (`ProfileName`).
|
||||||
|
pub name: String,
|
||||||
|
/// Where it came from, for the panel.
|
||||||
|
pub origin: ProfileOrigin,
|
||||||
|
pub hue_sat: Option<HueSatTable>,
|
||||||
|
pub look: Option<HueSatTable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a profile was found (camera-profiles.md §4).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProfileOrigin {
|
||||||
|
/// Embedded in the DNG being rendered.
|
||||||
|
Embedded,
|
||||||
|
/// A `.dcp` in the profiles directory, by file name.
|
||||||
|
File(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileTables {
|
||||||
|
/// Whether there is anything to apply.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.hue_sat.is_none() && self.look.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn identity(h: u32, s: u32, v: u32) -> HueSatTable {
|
||||||
|
HueSatTable::new(h, s, v, false, vec![[0.0, 1.0, 1.0]; (h * s * v) as usize]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saturation_varies_fastest_then_hue_then_value() {
|
||||||
|
let t = identity(4, 3, 2);
|
||||||
|
assert_eq!(t.index(0, 1, 0), 1);
|
||||||
|
assert_eq!(t.index(1, 0, 0), 3);
|
||||||
|
assert_eq!(t.index(0, 0, 1), 12);
|
||||||
|
assert_eq!(t.index(3, 2, 1), 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_that_lies_about_its_size_is_refused() {
|
||||||
|
assert!(HueSatTable::new(4, 3, 1, false, vec![[0.0, 1.0, 1.0]; 11]).is_none());
|
||||||
|
assert!(HueSatTable::new(4, 1, 1, false, vec![[0.0, 1.0, 1.0]; 4]).is_none());
|
||||||
|
assert!(HueSatTable::new(0, 3, 1, false, vec![]).is_none());
|
||||||
|
let mut bad = vec![[0.0, 1.0, 1.0]; 12];
|
||||||
|
bad[5][1] = f32::NAN;
|
||||||
|
assert!(HueSatTable::new(4, 3, 1, false, bad).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blending_two_illuminants_is_entry_by_entry() {
|
||||||
|
let a = identity(2, 2, 1);
|
||||||
|
let mut b = identity(2, 2, 1);
|
||||||
|
b.entries[3] = [10.0, 2.0, 0.5];
|
||||||
|
let mid = a.lerp(&b, 0.5).unwrap();
|
||||||
|
assert_eq!(mid.entries[3], [5.0, 1.5, 0.75]);
|
||||||
|
assert_eq!(a.lerp(&b, 0.0).unwrap(), a);
|
||||||
|
assert_eq!(a.lerp(&b, 1.0).unwrap(), b);
|
||||||
|
assert!(a.lerp(&identity(3, 2, 1), 0.5).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ use std::fmt;
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
pub mod colour;
|
pub mod colour;
|
||||||
|
pub mod hue_sat;
|
||||||
pub mod place;
|
pub mod place;
|
||||||
pub mod selector;
|
pub mod selector;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
|
||||||
pub use colour::{Chromaticities, Transfer};
|
pub use colour::{Chromaticities, Transfer};
|
||||||
|
pub use hue_sat::{HueSatTable, ProfileOrigin, ProfileTables};
|
||||||
pub use place::{Place, PlaceScope, Screen, StoredFilter};
|
pub use place::{Place, PlaceScope, Screen, StoredFilter};
|
||||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Camera profiles — DCP tables on top of the matrix
|
||||||
|
|
||||||
|
Design for the deferred half of **FR-DEV-3e** ([requirements.md](requirements.md)): the
|
||||||
|
`HueSatMap` and `LookTable` of a DNG camera profile, read from the DNG that carries one or from a
|
||||||
|
`.dcp` file, and applied after the matrix. Draft of 2026-10-02, recorded as **D20**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What we are matching
|
||||||
|
|
||||||
|
Lightroom renders a raw through a *profile* before any slider moves. A profile is the matrix
|
||||||
|
DarkRoom already applies, plus two lookup tables indexed by hue, saturation and value:
|
||||||
|
|
||||||
|
- **`ProfileHueSatMap`** — a calibration. It corrects what a 3×3 cannot: a sensor whose reds and
|
||||||
|
oranges sit in the wrong place relative to its blues, which no linear map fixes. Two copies, one
|
||||||
|
per calibration illuminant, interpolated like the matrices.
|
||||||
|
- **`ProfileLookTable`** — a rendering intent: hue shifts of up to ±18° by hue, and saturation
|
||||||
|
and value scales that vary with brightness. The difference between Adobe Standard, Adobe Color
|
||||||
|
and Adobe Vivid is largely this table, together with each profile's tone curve.
|
||||||
|
|
||||||
|
Without them a raw renders through the matrix alone, which is accurate on a ColorChecker and is
|
||||||
|
not what Lightroom showed for the same file.
|
||||||
|
|
||||||
|
**What the tables do *not* do is make a photograph more saturated.** Measured after the build
|
||||||
|
(2026-10-02), on four of the library's 6D DNGs rendered at defaults: Adobe Standard's tables
|
||||||
|
*lower* mean saturation by 3–9 %, and the look at 200 % lowers it further. The 6D's look table
|
||||||
|
scales saturation by 0.925 in its darkest value rows and by 1.0 from about a fifth of full scale
|
||||||
|
up, and its HueSatMap adds about 1 %. Adobe Standard was tuned to sit under Camera Raw's default
|
||||||
|
RGB tone curve, which raises saturation in the shadows and midtones, and the look's dark-tone
|
||||||
|
desaturation offsets it. Without that curve (§6) the offset is all that is left. On `_MG_9080`, Lightroom 6's own preview
|
||||||
|
measures 0.49 mean HSV saturation; the matrix alone renders 0.38, Adobe Standard's tables 0.35.
|
||||||
|
That preview also carries whatever was edited in Lightroom, so it is not a clean reference — but
|
||||||
|
the direction is unambiguous: **the gap to Lightroom's colour is mostly tone, not the profile's
|
||||||
|
tables.** The tables still matter for hue: they are what puts each body's reds, skin and foliage
|
||||||
|
where Adobe put them.
|
||||||
|
|
||||||
|
**What the library holds** (catalog of 2026-10-02): 17,286 of its raws are Canon EOS 6D. The
|
||||||
|
9,348 DNGs were written by Lightroom 6.14 and every one sampled embeds *Adobe Standard* with both
|
||||||
|
tables — `HueSatMapDims 90 30 1`, `LookTableDims 36 8 16`, `ProfileEmbedPolicy 0` ("allow
|
||||||
|
copying"), no `ProfileToneCurve`. The 7,938 CR2s from the same body carry no profile. So the
|
||||||
|
tables Lightroom used are already on disk for half the library, and their licence lets them be
|
||||||
|
applied to the other half.
|
||||||
|
|
||||||
|
## 2. The model, as the DNG specification states it
|
||||||
|
|
||||||
|
Each table is a grid of `(hueShift°, satScale, valScale)` triples over HSV, stored with
|
||||||
|
saturation varying fastest, then hue, then value:
|
||||||
|
|
||||||
|
```
|
||||||
|
index = v · (hueDivs · satDivs) + h · satDivs + s
|
||||||
|
```
|
||||||
|
|
||||||
|
Lookup follows the DNG SDK's `RefBaselineHueSatMap`:
|
||||||
|
|
||||||
|
- **HSV** is the SDK's: `v = max(r,g,b)`, `s = (v − min)/v`, `h ∈ [0, 6)` from which channel
|
||||||
|
leads. Grey has `s = 0` and is untouched by construction.
|
||||||
|
- **Hue wraps**: `hueDivs` samples over 360°, the last interpolating to the first.
|
||||||
|
- **Saturation** samples `0..=1` at `satDivs` points; linear between.
|
||||||
|
- **Value** samples `0..=1` at `valDivs` points when `valDivs > 1`; a table with `valDivs = 1` is
|
||||||
|
"2.5-D" and ignores value. `ProfileHueSatMapEncoding` / `ProfileLookTableEncoding` = 1 means the
|
||||||
|
value axis is indexed by the sRGB-encoded value; 0 (the default, and the 6D's) means linear.
|
||||||
|
- **Apply**: `h += hueShift · 6/360`, `s = min(s · satScale, 1)`, `v ·= valScale`; back to RGB.
|
||||||
|
- **Space**: linear ProPhoto (ROMM) primaries, D50 white — the space the forward matrix lands in.
|
||||||
|
- **Two illuminants**: `HueSatMapData1/2` are interpolated entry by entry, with the same mired
|
||||||
|
weight the matrices use. `LookTable` is single.
|
||||||
|
|
||||||
|
Two departures, both forced by D19's unbounded scene-linear values (the SDK runs these on `[0, 1]`):
|
||||||
|
|
||||||
|
1. **Value is not clamped.** The SDK writes `min(v · valScale, 1)`; here `v · valScale`, unbounded.
|
||||||
|
For lookup only, the value axis reads `min(v, 1)`, so a highlight above 1.0 uses the table's
|
||||||
|
brightest row. Where the encoding is sRGB the scale is defined on the encoded value; it is
|
||||||
|
applied as the ratio `decode(enc(v′)·valScale)/v′` at `v′ = min(v, 1)`, so a value above 1.0
|
||||||
|
gets the brightest row's ratio rather than a clip.
|
||||||
|
2. **A colour outside ProPhoto passes through.** A negative component has no SDK HSV. Such a
|
||||||
|
colour is outside every surface colour a camera records under normal light. It is left
|
||||||
|
unmodified rather than floored, because flooring it clips a value D19 says nothing may clip.
|
||||||
|
|
||||||
|
## 3. Where it sits
|
||||||
|
|
||||||
|
```
|
||||||
|
… camera matrix ─► vignetting(5) ─► exposure(20) ─► camera_profile(25) ─► contrast(30) ─► … ─► view transform
|
||||||
|
│
|
||||||
|
working → ProPhoto ─► HueSatMap ─► LookTable ─► ProPhoto → working
|
||||||
|
```
|
||||||
|
|
||||||
|
**A scene operation at order 25, not part of the matrix snippet.** Three reasons:
|
||||||
|
|
||||||
|
- **The matrix stays what it is.** `cam_to_srgb` is unchanged and still runs where D19 put it, and
|
||||||
|
so does every reader of it: the mask pass's copy, the white-balance picker's, the camera-space
|
||||||
|
tap. The tables add a conversion into ProPhoto and back *inside* their own fragment, through two
|
||||||
|
constant matrices (§3.1). A photograph with no profile composes exactly the shader it does today.
|
||||||
|
- **It commutes with what runs before it.** HSV hue and saturation are invariant under a uniform
|
||||||
|
gain, and vignetting and exposure are uniform gains. So a 2.5-D table — every Adobe HueSatMap
|
||||||
|
seen, and the 6D's — gives the same answer before or after them. That lets one position serve
|
||||||
|
both tables, which is where the second reason matters:
|
||||||
|
- **The look sees exposure.** The SDK applies `LookTable` after its exposure ramp, so a look that
|
||||||
|
desaturates highlights finds the highlights the photographer chose. At 25 it does too. Contrast,
|
||||||
|
tone and the colour controls come after it, as they do in Camera Raw.
|
||||||
|
|
||||||
|
**Tables are per source, like the matrix.** They are decoded with the raw, interpolated once at
|
||||||
|
decode (the HueSatMap blend uses the as-shot neutral, as the matrix does) and carried on
|
||||||
|
`DemosaicedImage` next to `color_matrix`. `dr-gpu` uploads them to a storage buffer at
|
||||||
|
`@binding(8)` and writes their dimensions into the base uniform block. Every render path that
|
||||||
|
reaches `AdjustPass` therefore gets them without being told: develop, export, previews, the tablet.
|
||||||
|
A path that had to call a setter on the graph would be a path that one day forgot to, and an export
|
||||||
|
that differed from the screen would be the result.
|
||||||
|
|
||||||
|
### 3.1 The two constants
|
||||||
|
|
||||||
|
`P⁻¹` is `ColourSpace::ProPhoto.from_linear_srgb()` — the conversion `dr-types` already derives
|
||||||
|
from the two spaces' chromaticities, adapting D65 to D50 by Bradford, which the export path uses to
|
||||||
|
write ProPhoto files — and `P` is its inverse. The fragment uses `P⁻¹` going in and `P` coming
|
||||||
|
out. Each row of both is scaled to sum to one, so working-space white is ProPhoto white exactly and
|
||||||
|
a neutral reaches the tables at `s = 0`. For a profile with forward matrices this recovers the
|
||||||
|
SDK's ProPhoto colour to within the difference between that derivation and `forward_to_srgb`'s
|
||||||
|
published Bradford constants, which is rounding.
|
||||||
|
|
||||||
|
## 4. Where a profile comes from
|
||||||
|
|
||||||
|
In this order, first match wins:
|
||||||
|
|
||||||
|
1. **The profile embedded in the DNG being opened.** It is what the file says, and it was made for
|
||||||
|
the matrices the file carries. Read from the root IFD through rawler's parsed `IFD`, as
|
||||||
|
`read_dng_matrices` already reads the forward matrices — no second TIFF parser.
|
||||||
|
2. **A `.dcp` in the profiles directory** whose `UniqueCameraModel` matches the body — compared
|
||||||
|
case-insensitively against the DNG's `UniqueCameraModel` where there is one, and against
|
||||||
|
`make + " " + model` otherwise ("Canon EOS 6D"). A DCP is a whole profile: its matrices replace
|
||||||
|
the file's, because its tables were built against its forward matrix. The file's as-shot neutral
|
||||||
|
is kept. If several match, the first by file name wins, so the choice is stable.
|
||||||
|
3. **None.** The matrix alone, as today.
|
||||||
|
|
||||||
|
The profiles directory is `profiles/` under the platform data directory (`dr_plat::dirs`), loaded
|
||||||
|
once per process. A DCP is a TIFF with the magic `IIRC` (0x4352) in place of 42; rawler's
|
||||||
|
`GenericTiffReader` already accepts it.
|
||||||
|
|
||||||
|
**Copying an embedded profile out.** A DNG whose profile has `ProfileEmbedPolicy` 0 ("allow
|
||||||
|
copying") or 3 ("no restrictions") can have that profile saved as a `.dcp` into the profiles
|
||||||
|
directory. That is how the 6D's CR2s get Adobe Standard: open a 6D DNG, choose *Use this profile
|
||||||
|
for every Canon EOS 6D*. Policies 1 ("embed if used") and 2 ("embed never") offer no such action.
|
||||||
|
The written file carries the profile's name, copyright and policy unchanged.
|
||||||
|
|
||||||
|
**Nothing is shipped.** Adobe's profiles are Adobe's; the application ships no `.dcp` and copies
|
||||||
|
none on its own. A profile reaches the directory because the photographer put it there or asked
|
||||||
|
for it to be copied from their own file.
|
||||||
|
|
||||||
|
## 5. The control
|
||||||
|
|
||||||
|
A develop operation, `camera_profile`, `[colour]`, order 25, hand-written (`rust:`) because it
|
||||||
|
reads a buffer no declaration can name:
|
||||||
|
|
||||||
|
| Parameter | Kind | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `apply` | Bool | on | Use the profile's tables, or the matrix alone |
|
||||||
|
| `look` | Scalar 0–200 | 100 | Strength of the `LookTable`, as Lightroom's *Amount* |
|
||||||
|
|
||||||
|
`look` scales the look's deltas: `hueShift · a`, `1 + (satScale − 1)·a`, `1 + (valScale − 1)·a`,
|
||||||
|
with `a = look/100`, scales floored at 0. At 200 the look is twice as strong, which is the
|
||||||
|
"more vivid than Adobe Standard" this started from. The HueSatMap is a calibration and is not
|
||||||
|
scaled: `apply` is its only switch.
|
||||||
|
|
||||||
|
**Always composed while `apply` is on**, as the view transform is: a profile at its defaults *is*
|
||||||
|
the rendering, not an edit, so an untouched photograph writes no parameters and still renders
|
||||||
|
through its profile. The fragment branches on the uniform that says whether the source has tables,
|
||||||
|
so a JPEG, or a raw with none, pays one uniform read. The branch is uniform across the dispatch.
|
||||||
|
|
||||||
|
**Mask layers.** A layer may offset `look` (blended as a setting, which is linear) but not `apply`;
|
||||||
|
the photograph has one profile.
|
||||||
|
|
||||||
|
**The panel says which profile is in use**, as the lens line does: *Adobe Standard (in the file)*,
|
||||||
|
*Adobe Standard (Canon EOS 6D.dcp)*, or *No profile for this camera — matrix only*. The copy-out
|
||||||
|
action sits on that line.
|
||||||
|
|
||||||
|
## 6. Not done, and why
|
||||||
|
|
||||||
|
- **`ProfileToneCurve` is read and ignored.** D19 gives tone to the view transform, one for every
|
||||||
|
body, and rejected per-body curves as its defaults. A profile's curve is a per-body curve. If it
|
||||||
|
comes back, it comes back as an option of the view transform, not as a stage here. The 6D's
|
||||||
|
Adobe Standard has none, so the case that matters today loses nothing.
|
||||||
|
- **`BaselineExposure` is not applied** (it is not today either). Adobe Standard was tuned with it,
|
||||||
|
and the 6D's is +0.25 EV. Separate change; it moves every photograph's brightness.
|
||||||
|
- **The interpolation follows the as-shot neutral, not the white-balance slider**, as the matrix
|
||||||
|
does. Camera Raw re-blends on every temperature change; doing so here means the matrix moves too,
|
||||||
|
which is its own change.
|
||||||
|
- **Masks select on the matrix's colour.** A colour-range mask sees colour before the profile, as
|
||||||
|
it sees colour before every other operation. Deterministic, and a mask is drawn on the picture
|
||||||
|
the user sees only approximately anyway.
|
||||||
|
- **The profiles directory does not sync.** A CR2 rendered on a desktop with a copied 6D profile
|
||||||
|
and on a tablet without one will differ. The panel line says which profile each device used, so
|
||||||
|
the difference is visible rather than silent. Syncing the directory with the library is the
|
||||||
|
follow-up.
|
||||||
|
- **Rec.2020 working primaries** stay deferred (D19); nothing here depends on them.
|
||||||
|
|
||||||
|
## 7. What it costs
|
||||||
|
|
||||||
|
- **Every DNG with an embedded profile renders differently** — more saturated, which is the point.
|
||||||
|
Previews rendered before the change keep the old look until rendered again, as with D19.
|
||||||
|
- **Tablet and desktop must be released together.** No schema change, and the sidecar gains only
|
||||||
|
ordinary parameters, but two builds render the same DNG differently.
|
||||||
|
- **One storage-buffer binding** in every generated shader's layout (a one-entry placeholder when
|
||||||
|
there are no tables), and two vec4 slots in the base uniform block.
|
||||||
|
- **Per pixel**: two 3×3 multiplies, two HSV round trips, and 4 + 8 buffer reads (bilinear
|
||||||
|
HueSatMap, trilinear LookTable). Small next to the fused pass it joins.
|
||||||
|
|
||||||
|
## 8. Acceptance
|
||||||
|
|
||||||
|
- **Parsing.** The 6D DNG's embedded profile parses to `90×30×1` and `36×8×16`, its policy to 0,
|
||||||
|
its name to "Adobe Standard"; a `.dcp` written from it parses back to the same tables bit for
|
||||||
|
bit.
|
||||||
|
- **The CPU reference matches the SDK's algorithm**: grey passes through; a table of
|
||||||
|
`(0°, 1, 1)` everywhere is the identity to 1e-6; a uniform `satScale` of 1.2 scales HSV
|
||||||
|
saturation by 1.2; hue interpolation wraps between the last and first column.
|
||||||
|
- **The shader agrees with the CPU reference** on a device, within two 8-bit codes of the
|
||||||
|
display-encoded readback (the only readback the adjust pass has), over 256 colours that tables
|
||||||
|
of tens of degrees and ±30 % saturation move, and over the library's real Adobe Standard tables
|
||||||
|
(`dr-gpu/tests/camera_profile.rs`).
|
||||||
|
- **Scene-referred.** A value above 1.0 leaves the stage above 1.0 (`scene_referred_until_the_view`
|
||||||
|
covers the operation).
|
||||||
|
- **Neutral.** `apply` off renders to the bit what a source with no tables renders.
|
||||||
|
- **Two illuminants.** A HueSatMap at blend weight 0 is Data1, at 1 is Data2.
|
||||||
|
- **Matching.** An embedded profile beats a directory one; a DCP for "Canon EOS 6D" matches a CR2
|
||||||
|
whose rawler make/model is "Canon"/"EOS 6D"; no match leaves the matrix.
|
||||||
|
- **Subjective.** A 6D DNG rendered here at defaults is visibly closer to the same file in
|
||||||
|
Lightroom 6 with Adobe Standard than the matrix-only render, side by side.
|
||||||
|
|
||||||
|
## 9. Vivid presets
|
||||||
|
|
||||||
|
Independent of the tables, and — given §1's measurement — the part of this change that actually
|
||||||
|
answers "more colourful". Shipped in the same change: a *Vivid* section of read-only presets
|
||||||
|
(`presets/vivid.drpl`) for the "more colourful than the default" request. They use only operations
|
||||||
|
every photograph has — vibrance, saturation, the colour mixer, colour grading, contrast — so they
|
||||||
|
work on JPEGs and on bodies with no profile, and change only what they name (FR-DEV-6):
|
||||||
|
|
||||||
|
- **Vivid** — vibrance and a little saturation and contrast: the general-purpose one.
|
||||||
|
- **Vivid, strong** — the same, pushed, with deeper blacks.
|
||||||
|
- **Vivid landscape** — greens, blues and azure skies, skin bands left alone.
|
||||||
|
- **Vivid warm** — oranges and yellows up, a warm highlight cast: golden hour.
|
||||||
|
- **Vivid portrait** — vibrance (which protects skin) with the orange and red bands held back.
|
||||||
|
|
||||||
|
Measured on `_MG_9080` (mean HSV saturation; Lightroom's preview 0.49, DarkRoom's default 0.35):
|
||||||
|
Vivid 0.40, Vivid strong 0.44, Vivid landscape 0.46, Vivid warm 0.38, Vivid portrait 0.37 — the
|
||||||
|
last two move particular bands, not the whole frame. Rendered with `cargo run --release -p dr-gpu
|
||||||
|
--example develop -- FILE.dng out.ppm "preset:Vivid"`.
|
||||||
|
|
||||||
|
They are bounded by the existing `bundled.rs` tests: every key names a real parameter, every value
|
||||||
|
is inside its control's range, and every preset changes something.
|
||||||
|
|
||||||
|
## 10. Build order
|
||||||
|
|
||||||
|
1. `dr-decode`: parse the tables (embedded and `.dcp`), the profiles directory, matching, blending,
|
||||||
|
the `.dcp` writer. CPU reference of the lookup. Unit tests against the library's 6D DNG,
|
||||||
|
skipped when it is absent.
|
||||||
|
2. `dr-pipeline`: the `camera_profile` operation, the base-block slots, `@binding(8)`, the WGSL
|
||||||
|
lookup; composition tests.
|
||||||
|
3. `dr-gpu`: carry the tables on `DemosaicedImage`, upload and bind them; the shader-versus-CPU
|
||||||
|
test on a device.
|
||||||
|
4. `dr-ui`: profile line, copy-out action, labels; the profiles directory set at start-up on
|
||||||
|
desktop and Android.
|
||||||
|
5. The *Vivid* presets.
|
||||||
@@ -53,6 +53,13 @@ left outstanding, and its DCP half stays deferred as before. §4's FR-DSP-2 and
|
|||||||
record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame
|
record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame
|
||||||
choice, which changes how FR-MRG-5 is met rather than whether.
|
choice, which changes how FR-MRG-5 is met rather than whether.
|
||||||
|
|
||||||
|
**And for 0.20.0.** FR-DEV-3e's DCP half is built (D20, [camera-profiles.md](camera-profiles.md)):
|
||||||
|
the HueSatMap and LookTable from a DNG's embedded profile or a matched `.dcp`. Two pieces stay
|
||||||
|
open, both named in that design's §6: the profiles directory does not sync, so a CR2 can render
|
||||||
|
with a copied profile on one device and without it on another; and `ProfileToneCurve` and
|
||||||
|
`BaselineExposure` are read and not applied. The measurement in its §1 says the second is where the
|
||||||
|
remaining gap to Lightroom's colour lies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Plugins — post-v1 since 2026-09-19
|
## 1. Plugins — post-v1 since 2026-09-19
|
||||||
|
|||||||
+35
-4
@@ -367,7 +367,7 @@ Built, on branch `merge/panorama`, in the order §10 gave:
|
|||||||
| The camera-space tap | `OutputMode::CameraLinear`, `AdjustPass::render_camera_linear` | Done, `rgba32float`, tiles by view rect |
|
| The camera-space tap | `OutputMode::CameraLinear`, `AdjustPass::render_camera_linear` | Done, `rgba32float`, tiles by view rect |
|
||||||
| Linear DNG writer, streamed | `dr_export::write_linear_dng` | Done; rawler reads it back |
|
| Linear DNG writer, streamed | `dr_export::write_linear_dng` | Done; rawler reads it back |
|
||||||
| A three-sample `RawImage` re-entering the pipeline | `dr-decode`, `DemosaicedImage::from_linear_rgb16` | Done |
|
| A three-sample `RawImage` re-entering the pipeline | `dr-decode`, `DemosaicedImage::from_linear_rgb16` | Done |
|
||||||
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; feathered blend, scalar gain |
|
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; seams (§11.1) over a feather, scalar gain |
|
||||||
| The job: load, proxies, align, gains, confirm, merge, provenance | `dr_ui::merge` | Done; `examples/merge.rs` drives it headless |
|
| The job: load, proxies, align, gains, confirm, merge, provenance | `dr_ui::merge` | Done; `examples/merge.rs` drives it headless |
|
||||||
| The page: table, preview, projection, Merge/Stop/Back; the grid's button | `merge.slint`, `merge_ui.rs` | Done; `DARKROOM_START_MERGE=a.CR2,b.CR2` lands on it |
|
| The page: table, preview, projection, Merge/Stop/Back; the grid's button | `merge.slint`, `merge_ui.rs` | Done; `DARKROOM_START_MERGE=a.CR2,b.CR2` lands on it |
|
||||||
| Placement beside the sources through the outbox, rescan | `merge_ui.rs` | Done, untested against a server |
|
| Placement beside the sources through the outbox, rescan | `merge_ui.rs` | Done, untested against a server |
|
||||||
@@ -385,9 +385,11 @@ half.
|
|||||||
the file carries the black border. The largest inscribed rectangle over
|
the file carries the black border. The largest inscribed rectangle over
|
||||||
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
|
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
|
||||||
nothing is thrown away and the develop view opens on the picture.
|
nothing is thrown away and the develop view opens on the picture.
|
||||||
2. **Seams and the pyramid** (§10 step 5). The feather hides exposure and
|
2. **The pyramid** (§10 step 5). Seams landed 2026-09-30 (§11.1); the
|
||||||
small misalignment; parallax on the near slope will show as a soft
|
blend across them is one width for every frequency, so an exposure step
|
||||||
double edge at 1:1.
|
the gains leave is narrowed to the seam's 64 px rather than hidden over
|
||||||
|
the old 200. A Laplacian pyramid would blend low frequencies wide and
|
||||||
|
detail narrow.
|
||||||
3. **Vignetting in the tap.** The lens profile's distortion is applied
|
3. **Vignetting in the tap.** The lens profile's distortion is applied
|
||||||
before the fetch; its vignetting is an operation and is not. Frame edges
|
before the fetch; its vignetting is an operation and is not. Frame edges
|
||||||
are darker than their centres by the lens's falloff, and the feather
|
are darker than their centres by the lens's falloff, and the feather
|
||||||
@@ -402,6 +404,35 @@ half.
|
|||||||
catalog's `content_hash` is null for most images most of the time. The
|
catalog's `content_hash` is null for most images most of the time. The
|
||||||
hash can join it when the catalog has one.
|
hash can join it when the catalog has one.
|
||||||
|
|
||||||
|
### 11.1 Seams — 2026-09-30
|
||||||
|
|
||||||
|
The feather averaged every overlap over 200 px, so anything the frames
|
||||||
|
disagreed on — parallax on the near slope, a walker, wind in a branch — came
|
||||||
|
out twice at half strength: a soft double edge at 1:1, reported as a glitch.
|
||||||
|
|
||||||
|
`dr_pano::seam` now chooses, per output texel at proxy resolution, which
|
||||||
|
frame it is taken from. Frames are laid down nearest-first; where a new one
|
||||||
|
overlaps the composite, each texel costs the gain-corrected difference
|
||||||
|
between the two, plus the detail either has there, plus nearness to either
|
||||||
|
frame's edge (vignetting, the lens correction's fringe), taken as the
|
||||||
|
**worst** over a 4-texel window so the path stays a blend radius clear of a
|
||||||
|
difference rather than grazing it. The cut is a dynamic-programming path
|
||||||
|
across the overlap, perpendicular to the line from the composite's frames to
|
||||||
|
the new one: §4's per-column seam, not a graph cut. The map is computed per
|
||||||
|
projection, for the page's preview and again for the merge.
|
||||||
|
|
||||||
|
`merge.wgsl` weights a frame by its tent-filtered share of the label map
|
||||||
|
about each pixel (`SeamMap::share`, repeated verbatim), over a window
|
||||||
|
`seam_blend_px` wide (64, capped at 4 texels either side). The edge feather
|
||||||
|
remains underneath as a factor and, with a 1e-4 floor, as the answer where
|
||||||
|
the map names no frame that reaches the pixel. `--feather-only` on
|
||||||
|
`examples/merge.rs` merges the old way, for comparison.
|
||||||
|
|
||||||
|
Known limits: one axis per new frame, so in a multi-row set a frame
|
||||||
|
overlapping its left neighbour and the row above is cut along a compromise
|
||||||
|
direction; the cost reads grey proxies, so a difference in hue alone is
|
||||||
|
invisible to it.
|
||||||
|
|
||||||
## 12. Filling the border instead of cropping it — MI-GAN, read and measured 2026-09-19
|
## 12. Filling the border instead of cropping it — MI-GAN, read and measured 2026-09-19
|
||||||
|
|
||||||
Raised after the first merges: the ragged border a cylinder leaves could be
|
Raised after the first merges: the ragged border a cylinder leaves could be
|
||||||
|
|||||||
@@ -433,9 +433,18 @@ camera RGB, where its multipliers are defined, and every other operation receive
|
|||||||
colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the
|
colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the
|
||||||
colour mixer and the weights in `luminance()` meant something different on every body.
|
colour mixer and the weights in `luminance()` meant something different on every body.
|
||||||
|
|
||||||
**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
|
~~**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
|
||||||
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
|
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
|
||||||
pipeline reordering.
|
pipeline reordering.~~ *Amended 2026-10-02 (D20):* dual-illuminant interpolation of the matrices
|
||||||
|
was built with item 1. The tables follow, designed in [camera-profiles.md](camera-profiles.md):
|
||||||
|
|
||||||
|
4. **DCP tables.** `ProfileHueSatMap` (both illuminants, blended as the matrices are) and
|
||||||
|
`ProfileLookTable`, read from the profile embedded in a DNG or from a `.dcp` file in the
|
||||||
|
profiles directory matched by `UniqueCameraModel`, the embedded one first. They are applied by a
|
||||||
|
`camera_profile` scene operation after exposure, with a switch and a look strength (0–200 %),
|
||||||
|
on by default where a profile exists. `ProfileToneCurve` is read and not applied: tone is the
|
||||||
|
view transform's (D19). An embedded profile whose `ProfileEmbedPolicy` allows copying can be
|
||||||
|
saved as a `.dcp` for other files from the same body. The application ships no profile.
|
||||||
|
|
||||||
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
|
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
|
||||||
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
|
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
|
||||||
@@ -448,6 +457,9 @@ measurements, and their provenance was not known well enough to keep them as def
|
|||||||
*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
|
*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
|
||||||
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
|
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
|
||||||
once DCP support lands.
|
once DCP support lands.
|
||||||
|
For item 4: the lookup follows the DNG SDK's on `[0, 1]` and leaves values above 1.0 above it;
|
||||||
|
grey and an identity table pass through unchanged; the shader agrees with the CPU reference; with
|
||||||
|
the switch off the render is to the bit the one with no profile (camera-profiles.md §8).
|
||||||
|
|
||||||
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
|
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
|
||||||
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
|
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
|
||||||
@@ -2425,6 +2437,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
|
|||||||
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
|
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
|
||||||
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
|
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
|
||||||
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
|
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
|
||||||
|
| D20 | DCP camera profiles | **DECIDED 2026-10-02** — HueSatMap and LookTable as a scene operation after exposure; embedded profile first, then a matched `.dcp`; tone curve not applied; none shipped |
|
||||||
|
|
||||||
### D11 — product positioning
|
### D11 — product positioning
|
||||||
|
|
||||||
@@ -2730,6 +2743,35 @@ unbounded, but several fragments floor at zero, which clips a colour outside sRG
|
|||||||
mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression
|
mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression
|
||||||
beyond the output transform's clip goes with it.
|
beyond the output transform's clip goes with it.
|
||||||
|
|
||||||
|
### D20 — DCP camera profiles · **DECIDED 2026-10-02**
|
||||||
|
|
||||||
|
**A camera profile's `HueSatMap` and `LookTable` are applied by a `camera_profile` scene
|
||||||
|
operation at order 25, after exposure, converting into linear ProPhoto and back inside its own
|
||||||
|
fragment.** Design and the full argument: [camera-profiles.md](camera-profiles.md).
|
||||||
|
|
||||||
|
*Why now.* The library's 9,348 Canon 6D DNGs carry Adobe Standard's tables, which Lightroom
|
||||||
|
rendered them through, and DarkRoom ignored them, so every hue on those files sat somewhere other
|
||||||
|
than where Lightroom put it. *Measured after building it:* the tables are not why Lightroom's
|
||||||
|
rendering looks richer — at defaults they lower mean saturation by 3–9 %, because Adobe Standard's
|
||||||
|
look desaturates dark tones to sit under Camera Raw's tone curve, which DarkRoom does not apply.
|
||||||
|
The richer colour is tone, and the Vivid presets (FR-DEV-6) are what answers it today
|
||||||
|
(camera-profiles.md §1).
|
||||||
|
|
||||||
|
*Why there.* The matrix snippet stays what D19 made it, and every copy of it (masks, picker,
|
||||||
|
camera-space tap) stays correct without changing. Hue and saturation are invariant under the
|
||||||
|
uniform gains that precede order 25, so a 2.5-D HueSatMap gives the same answer there as straight
|
||||||
|
after the matrix, and the LookTable sees the photographer's exposure, as it does in the SDK.
|
||||||
|
|
||||||
|
*Rejected.* Extending the matrix snippet: every duplicate of it would have had to follow. Two
|
||||||
|
operations, one per table: the HueSatMap has no control of its own and commutes to the same place.
|
||||||
|
Applying `ProfileToneCurve`: a per-body tone curve is what D19 retired. Shipping Adobe's profiles:
|
||||||
|
they are not ours to ship. Handing the tables to the graph through a setter, as lens profiles are:
|
||||||
|
every render path would have to remember to call it. They travel with the decoded image, as the
|
||||||
|
matrix does.
|
||||||
|
|
||||||
|
*What it costs.* Every DNG with an embedded profile renders differently; previews refresh only when
|
||||||
|
rendered again; tablet and desktop release together. The profiles directory does not sync yet.
|
||||||
|
|
||||||
### D16 — plugin licensing · **OPEN, post-v1**
|
### D16 — plugin licensing · **OPEN, post-v1**
|
||||||
|
|
||||||
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
||||||
|
|||||||
+105
-105
File diff suppressed because one or more lines are too long
+24
-24
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1982`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2029`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2078`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2125`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2169`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2216`</sub>
|
||||||
|
|
||||||
### Open this list
|
### Open this list
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
|||||||
|
|
||||||
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2395`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2442`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2425`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2472`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
||||||
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2439`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
|
||||||
|
|
||||||
### Remove a repair
|
### Remove a repair
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, then Delete Repair
|
- **Pointer** — Click it, then Delete Repair
|
||||||
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2459`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2506`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2478`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2491`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2538`</sub>
|
||||||
|
|
||||||
### Choose which kinds of edit a copy carries
|
### Choose which kinds of edit a copy carries
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2509`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2556`</sub>
|
||||||
|
|
||||||
### Export this photograph as the last one was
|
### Export this photograph as the last one was
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
|
|||||||
|
|
||||||
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2534`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2581`</sub>
|
||||||
|
|
||||||
### Choose how to export, then export
|
### Choose how to export, then export
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
|
|||||||
|
|
||||||
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2547`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2594`</sub>
|
||||||
|
|
||||||
### Keep a crop that leaves a mask outside
|
### Keep a crop that leaves a mask outside
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||||
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2612`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2659`</sub>
|
||||||
|
|
||||||
### Go back to the grid
|
### Go back to the grid
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
|
|
||||||
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2629`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2676`</sub>
|
||||||
|
|
||||||
### Nudge the control last moved
|
### Nudge the control last moved
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
|
|||||||
|
|
||||||
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2658`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2705`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2686`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2733`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2722`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2769`</sub>
|
||||||
|
|
||||||
### Rate this photograph
|
### Rate this photograph
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
- **Pointer** — Click a star in the top bar
|
- **Pointer** — Click a star in the top bar
|
||||||
- **Keyboard** — `0`–`5`
|
- **Keyboard** — `0`–`5`
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2779`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2826`</sub>
|
||||||
|
|
||||||
### Pick or reject this photograph
|
### Pick or reject this photograph
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2785`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2832`</sub>
|
||||||
|
|
||||||
### Give this photograph a colour label
|
### Give this photograph a colour label
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2815`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2862`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2840`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2887`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2970`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:3012`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
|
|||||||
|
|
||||||
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1024`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1026`</sub>
|
||||||
|
|
||||||
### Do what a sheet offers
|
### Do what a sheet offers
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
|
|||||||
- **Pointer** — Press its button — Export, or Copy
|
- **Pointer** — Press its button — Export, or Copy
|
||||||
- **Keyboard** — `Enter`, on the export and copy sheets
|
- **Keyboard** — `Enter`, on the export and copy sheets
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1034`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1036`</sub>
|
||||||
|
|
||||||
### Scroll by the scrollbar
|
### Scroll by the scrollbar
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.19.1
|
pkgver=0.20.0
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! FR-CAT-10 asks for removable-volume insertion to be detected "where the
|
//! FR-CAT-10 asks for removable-volume insertion to be detected "where the
|
||||||
//! platform permits", which is a careful phrase and this module is why. There
|
//! platform permits", which is a careful phrase and this module is why. There
|
||||||
//! is no portable answer: Linux has a mount table and a sysfs flag, Android
|
//! is no portable answer: Linux has a mount table and a sysfs flag, Android
|
||||||
//! has neither and hands out a document tree the user picked (ARCH §6.9).
|
//! has neither and lists its volumes through a Java service (`dr_ui::cards`).
|
||||||
//! So this reports what it can and returns an empty list where it cannot,
|
//! So this reports what it can and returns an empty list where it cannot,
|
||||||
//! and every caller must still offer the user a way to say where the card is.
|
//! and every caller must still offer the user a way to say where the card is.
|
||||||
//!
|
//!
|
||||||
@@ -67,22 +67,18 @@ impl Volume {
|
|||||||
/// where it is mounted. `false` here means the operation cannot be performed
|
/// where it is mounted. `false` here means the operation cannot be performed
|
||||||
/// however hard the user tries, and the interface should not offer it.
|
/// however hard the user tries, and the interface should not offer it.
|
||||||
///
|
///
|
||||||
/// It is `false` on Android, for two reasons that both have to be fixed before
|
/// It is `true` on Linux and on Android. On Android the card is read by
|
||||||
/// it can change:
|
/// path too — `/storage/9C33-6BBD` — once the user has granted "all files
|
||||||
|
/// access", but finding it takes the platform's `StorageManager`, which is
|
||||||
|
/// Java. So [`volumes`] still answers empty there, and `dr_ui::cards` lists
|
||||||
|
/// the volumes and asks for the permission instead.
|
||||||
///
|
///
|
||||||
/// - There is no mount table to read and no path to type. Storage is reached
|
/// The engine above this takes storage traits and never a path, and the
|
||||||
/// through a tree the user granted, and a removable volume appears there or
|
/// importer only ever *writes* into its own staging directory, which is a
|
||||||
/// not at all (ARCH §6.9).
|
/// plain directory on every platform. So what this gates is whether a card
|
||||||
/// - Nothing implements [`WritableStorage`](crate::WritableStorage) except
|
/// can be *read*, nothing more.
|
||||||
/// [`LocalStorage`](crate::LocalStorage), so there is no destination to write
|
|
||||||
/// into even once a source is named.
|
|
||||||
///
|
|
||||||
/// The engine above this is already portable — it takes storage traits and
|
|
||||||
/// never a path — so what this gates is the *interface*, and it stops being
|
|
||||||
/// `false` when a SAF implementation lands rather than when the importer is
|
|
||||||
/// rewritten.
|
|
||||||
pub const fn imports_supported() -> bool {
|
pub const fn imports_supported() -> bool {
|
||||||
cfg!(target_os = "linux")
|
cfg!(any(target_os = "linux", target_os = "android"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every mounted volume that might hold photographs.
|
/// Every mounted volume that might hold photographs.
|
||||||
@@ -130,9 +126,9 @@ fn platform_volumes() -> Vec<Volume> {
|
|||||||
|
|
||||||
/// Everywhere else: no answer, and saying so is the honest result.
|
/// Everywhere else: no answer, and saying so is the honest result.
|
||||||
///
|
///
|
||||||
/// On Android the question is not merely unanswerable but wrong — storage is
|
/// On Android there is no readable mount table either; the volumes come from
|
||||||
/// reached through a tree the user granted, and a card appears there or not at
|
/// `StorageManager` through `dr_ui::cards`, which needs the JNI this crate
|
||||||
/// all (ARCH §6.9, FR-PLAT-AND-1).
|
/// does not have.
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn platform_volumes() -> Vec<Volume> {
|
fn platform_volumes() -> Vec<Volume> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -358,7 +354,10 @@ tmpfs /run/user/1000 tmpfs rw,nosuid 0 0
|
|||||||
// The two are different claims: an empty list means "plug one in",
|
// The two are different claims: an empty list means "plug one in",
|
||||||
// `false` here means "this cannot be done here". An interface that
|
// `false` here means "this cannot be done here". An interface that
|
||||||
// conflated them would offer a page that can never be used.
|
// conflated them would offer a page that can never be used.
|
||||||
assert_eq!(imports_supported(), cfg!(target_os = "linux"));
|
assert_eq!(
|
||||||
|
imports_supported(),
|
||||||
|
cfg!(any(target_os = "linux", target_os = "android"))
|
||||||
|
);
|
||||||
if !imports_supported() {
|
if !imports_supported() {
|
||||||
assert!(volumes().is_empty());
|
assert!(volumes().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! `--feather-only` merges without seams — every overlap the feathered
|
||||||
|
//! average — for comparing the two on the same set.
|
||||||
|
//!
|
||||||
//! `--leave-out N` unticks frame `N` once the first alignment is in, as the
|
//! `--leave-out N` unticks frame `N` once the first alignment is in, as the
|
||||||
//! page's box would, so the job aligns again without it.
|
//! page's box would, so the job aligns again without it.
|
||||||
//!
|
//!
|
||||||
@@ -25,6 +28,7 @@ fn main() {
|
|||||||
let mut fill = false;
|
let mut fill = false;
|
||||||
let mut wait_engines = false;
|
let mut wait_engines = false;
|
||||||
let mut leave_out: Option<usize> = None;
|
let mut leave_out: Option<usize> = None;
|
||||||
|
let mut seams = true;
|
||||||
loop {
|
loop {
|
||||||
match args.first().map(String::as_str) {
|
match args.first().map(String::as_str) {
|
||||||
Some("--out") => {
|
Some("--out") => {
|
||||||
@@ -39,6 +43,10 @@ fn main() {
|
|||||||
args.remove(0);
|
args.remove(0);
|
||||||
leave_out = args.remove(0).parse().ok();
|
leave_out = args.remove(0).parse().ok();
|
||||||
}
|
}
|
||||||
|
Some("--feather-only") => {
|
||||||
|
args.remove(0);
|
||||||
|
seams = false;
|
||||||
|
}
|
||||||
Some("--wait-engines") => {
|
Some("--wait-engines") => {
|
||||||
args.remove(0);
|
args.remove(0);
|
||||||
wait_engines = true;
|
wait_engines = true;
|
||||||
@@ -101,6 +109,7 @@ fn main() {
|
|||||||
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||||
// The filler from the shared models directory, as the app finds it.
|
// The filler from the shared models directory, as the app finds it.
|
||||||
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
||||||
|
request.seams = seams;
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let (decide, decision) = std::sync::mpsc::channel();
|
let (decide, decision) = std::sync::mpsc::channel();
|
||||||
let cancel = dr_ui::merge::Cancel::default();
|
let cancel = dr_ui::merge::Cancel::default();
|
||||||
|
|||||||
@@ -121,6 +121,20 @@ impl AlbumsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file names an album already records, for a batch bound for its
|
||||||
|
/// server folder to name around. Empty where the catalog cannot say.
|
||||||
|
pub fn file_names(&self, album: AlbumId) -> std::collections::HashSet<String> {
|
||||||
|
let catalog = self.library.catalog();
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else {
|
||||||
|
return Default::default();
|
||||||
|
};
|
||||||
|
albums::file_names(cat.connection(), album).unwrap_or_else(|e| {
|
||||||
|
log::warn!("reading the files of album {}: {e}", album.0);
|
||||||
|
Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Record what a batch wrote into an album, and redraw what counts it.
|
/// Record what a batch wrote into an album, and redraw what counts it.
|
||||||
pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) {
|
pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) {
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! TRACES: FR-CAT-10 | NFR-PORT-1
|
||||||
|
//! Finding a camera card on Android, and the permission that makes it readable.
|
||||||
|
//!
|
||||||
|
//! `dr_plat::volumes` reads the mount table, which Android does not let an
|
||||||
|
//! app read; the volumes are listed by `StorageManager`, which is Java. So
|
||||||
|
//! this is the Android half of "where is the card": `Cards.java` does the
|
||||||
|
//! asking and this is the JNI bridge to it, through the helpers `saf` already
|
||||||
|
//! has.
|
||||||
|
//!
|
||||||
|
//! What comes back is an ordinary path — `/storage/9C33-6BBD` — and the rest
|
||||||
|
//! of the import reads it exactly as it reads `/run/media/…` on the desktop.
|
||||||
|
//! That only works once the user has granted "all files access", which is
|
||||||
|
//! [`has_access`] and [`request_access`].
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Whether the app may read a card's files by path.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn has_access() -> bool {
|
||||||
|
crate::saf::call("checking card access", |env, context| {
|
||||||
|
let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?;
|
||||||
|
env.call_static_method(
|
||||||
|
&cls,
|
||||||
|
jni::jni_str!("hasAccess"),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)Z"),
|
||||||
|
&[context.into()],
|
||||||
|
)?
|
||||||
|
.z()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("{e}");
|
||||||
|
false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the system page where the user grants it.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn request_access() {
|
||||||
|
let opened = crate::saf::call("asking for card access", |env, context| {
|
||||||
|
let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?;
|
||||||
|
env.call_static_method(
|
||||||
|
&cls,
|
||||||
|
jni::jni_str!("requestAccess"),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||||
|
&[context.into()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
if let Err(e) = opened {
|
||||||
|
log::warn!("{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every mounted volume that is not the device's own storage.
|
||||||
|
///
|
||||||
|
/// Empty when there is none, and when the platform could not be asked — the
|
||||||
|
/// page says "insert a card" either way, which is the thing to do in both.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn volumes() -> Vec<dr_plat::Volume> {
|
||||||
|
let lines = crate::saf::call("listing storage volumes", |env, context| {
|
||||||
|
let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?;
|
||||||
|
let value = env
|
||||||
|
.call_static_method(
|
||||||
|
&cls,
|
||||||
|
jni::jni_str!("volumes"),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)[Ljava/lang/String;"),
|
||||||
|
&[context.into()],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let array = env.cast_local::<jni::objects::JObjectArray>(value)?;
|
||||||
|
let count = array.len(env)?;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let element = array.get_element(env, i)?;
|
||||||
|
if let Some(line) = crate::saf::text(env, element)? {
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("{e}");
|
||||||
|
Vec::new()
|
||||||
|
});
|
||||||
|
let mut found: Vec<dr_plat::Volume> = lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| parse(line))
|
||||||
|
.map(|(path, label, removable)| dr_plat::Volume {
|
||||||
|
has_dcim: path.join("DCIM").is_dir(),
|
||||||
|
label,
|
||||||
|
removable,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// The order `dr_plat::volumes` gives: likely cards first, then by name.
|
||||||
|
found.sort_by(|a, b| {
|
||||||
|
b.is_likely_card()
|
||||||
|
.cmp(&a.is_likely_card())
|
||||||
|
.then_with(|| a.label.cmp(&b.label))
|
||||||
|
});
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line from `Cards.volumes`: `path \t description \t removable`.
|
||||||
|
///
|
||||||
|
/// `None` for a line that does not have the three fields, which would be a
|
||||||
|
/// mismatch between the two halves rather than anything a device does.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
fn parse(line: &str) -> Option<(PathBuf, String, bool)> {
|
||||||
|
let mut fields = line.split('\t');
|
||||||
|
let path = fields.next().filter(|p| !p.is_empty())?;
|
||||||
|
let label = fields.next()?;
|
||||||
|
let removable = fields.next()? == "1";
|
||||||
|
if fields.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((PathBuf::from(path), label.to_string(), removable))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_volume_line_is_read_as_cards_java_writes_it() {
|
||||||
|
// The format is a contract between two files in two languages, and
|
||||||
|
// only a device would otherwise notice it break.
|
||||||
|
let java = include_str!(
|
||||||
|
"../../../apps/darkroom-android/android/java/paris/tourolle/darkroom/Cards.java"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
java.contains(r#"out.add(path + "\t" + description.replace('\t', ' ') + "\t""#),
|
||||||
|
"Cards.volumes no longer writes the line this parses"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse("/storage/9C33-6BBD\tSanDisk SD card\t1"),
|
||||||
|
Some((
|
||||||
|
PathBuf::from("/storage/9C33-6BBD"),
|
||||||
|
"SanDisk SD card".to_string(),
|
||||||
|
true
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse("/storage/1234-ABCD\tUSB drive\t0").map(|v| v.2),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_line_is_dropped_rather_than_guessed_at() {
|
||||||
|
assert_eq!(parse(""), None);
|
||||||
|
assert_eq!(parse("/storage/9C33-6BBD"), None);
|
||||||
|
assert_eq!(parse("\tlabel\t1"), None);
|
||||||
|
assert_eq!(parse("/a\tb\t1\textra"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ impl Decoder for Stub {
|
|||||||
},
|
},
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: "Stubco".into(),
|
make: "Stubco".into(),
|
||||||
model: "Stubco One".into(),
|
model: "Stubco One".into(),
|
||||||
})
|
})
|
||||||
|
|||||||
+180
-30
@@ -523,12 +523,18 @@ impl DevelopSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||||||
|
///
|
||||||
|
/// Read off the *shorter* extent of the view. The view takes the
|
||||||
|
/// viewport's shape (see [`view_extents`]), so the axis that limited the
|
||||||
|
/// fit is the one that shrinks as 1/zoom, while the other may still be
|
||||||
|
/// showing the whole frame across.
|
||||||
pub fn zoom(&self) -> f32 {
|
pub fn zoom(&self) -> f32 {
|
||||||
let v = self.graph.framing().view();
|
let v = self.graph.framing().view();
|
||||||
if v.width <= 0.0 {
|
let shorter = v.width.min(v.height);
|
||||||
|
if shorter <= 0.0 {
|
||||||
1.0
|
1.0
|
||||||
} else {
|
} else {
|
||||||
1.0 / v.width
|
1.0 / shorter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,16 +549,22 @@ impl DevelopSession {
|
|||||||
/// magnifying the photograph rather than sliding it around.
|
/// magnifying the photograph rather than sliding it around.
|
||||||
///
|
///
|
||||||
/// `factor` multiplies the current zoom — above 1 moves in.
|
/// `factor` multiplies the current zoom — above 1 moves in.
|
||||||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
///
|
||||||
|
/// `viewport_w`/`viewport_h` give the view its shape; see
|
||||||
|
/// [`view_extents`]. Only their ratio matters, so a draft frame's halved
|
||||||
|
/// viewport would do as well as the full one.
|
||||||
|
pub fn zoom_about(
|
||||||
|
&mut self,
|
||||||
|
factor: f32,
|
||||||
|
at_x: f32,
|
||||||
|
at_y: f32,
|
||||||
|
viewport_w: u32,
|
||||||
|
viewport_h: u32,
|
||||||
|
) {
|
||||||
const MAX_ZOOM: f32 = 16.0;
|
const MAX_ZOOM: f32 = 16.0;
|
||||||
|
|
||||||
let view = self.graph.framing().view();
|
let view = self.graph.framing().view();
|
||||||
let current = if view.width > 0.0 {
|
let target = (self.zoom() * factor).clamp(1.0, MAX_ZOOM);
|
||||||
1.0 / view.width
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
|
||||||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||||||
// stopping a fraction short and leaving the image imperceptibly
|
// stopping a fraction short and leaving the image imperceptibly
|
||||||
// panned.
|
// panned.
|
||||||
@@ -562,7 +574,7 @@ impl DevelopSession {
|
|||||||
target
|
target
|
||||||
};
|
};
|
||||||
|
|
||||||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
let (ew, eh) = self.view_extents(target, viewport_w, viewport_h);
|
||||||
|
|
||||||
// The point under the cursor, in framed coordinates, must land back
|
// The point under the cursor, in framed coordinates, must land back
|
||||||
// under the cursor afterwards.
|
// under the cursor afterwards.
|
||||||
@@ -570,19 +582,53 @@ impl DevelopSession {
|
|||||||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||||||
|
|
||||||
self.set_view_clamped(
|
self.set_view_clamped(
|
||||||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
anchor_x - at_x.clamp(0.0, 1.0) * ew,
|
||||||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
anchor_y - at_y.clamp(0.0, 1.0) * eh,
|
||||||
extent,
|
(ew, eh),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// Keep a zoomed view the shape of the viewport it is drawn in.
|
||||||
|
///
|
||||||
|
/// The view is shaped when it is zoomed, but the viewport can change under
|
||||||
|
/// it — a window resized, a panel opened, a crop that changes the frame's
|
||||||
|
/// aspect — and a view left in the old shape letterboxes again. Re-cut
|
||||||
|
/// about its centre at the same zoom; a fitted view is left alone, since
|
||||||
|
/// fitting is the whole frame whatever the box.
|
||||||
|
///
|
||||||
|
/// Free when nothing moved: the view is only written when its shape is
|
||||||
|
/// out by more than float noise, so a redraw per frame does not churn it.
|
||||||
|
pub fn shape_view_to(&mut self, viewport_w: u32, viewport_h: u32) {
|
||||||
|
if !self.is_zoomed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let view = self.graph.framing().view();
|
||||||
|
let (ew, eh) = self.view_extents(self.zoom(), viewport_w, viewport_h);
|
||||||
|
if (ew - view.width).abs() < 1e-4 && (eh - view.height).abs() < 1e-4 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (cx, cy) = self.inspection_point();
|
||||||
|
self.set_view_clamped(cx - ew / 2.0, cy - eh / 2.0, (ew, eh));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view's extents at `zoom` in this viewport; see [`view_extents`].
|
||||||
|
fn view_extents(&self, zoom: f32, viewport_w: u32, viewport_h: u32) -> (f32, f32) {
|
||||||
|
let (sw, sh) = self.demosaiced.size();
|
||||||
|
view_extents(
|
||||||
|
self.graph.output_size(sw, sh),
|
||||||
|
zoom,
|
||||||
|
(viewport_w, viewport_h),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||||||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||||||
let view = self.graph.framing().view();
|
let view = self.graph.framing().view();
|
||||||
self.set_view_clamped(
|
self.set_view_clamped(
|
||||||
view.x + dx * view.width,
|
view.x + dx * view.width,
|
||||||
view.y + dy * view.height,
|
view.y + dy * view.height,
|
||||||
view.width,
|
(view.width, view.height),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,9 +682,9 @@ impl DevelopSession {
|
|||||||
/// and this is the next photograph arriving under the magnifier the last
|
/// and this is the next photograph arriving under the magnifier the last
|
||||||
/// one was left under.
|
/// one was left under.
|
||||||
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
|
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
|
||||||
let extent =
|
let zoom = self.one_to_one_zoom(viewport_w, viewport_h);
|
||||||
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
|
let (ew, eh) = self.view_extents(zoom, viewport_w, viewport_h);
|
||||||
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
|
self.set_view_clamped(x - ew / 2.0, y - eh / 2.0, (ew, eh));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-UI-4 | FR-DEV-3
|
/// TRACES: FR-UI-4 | FR-DEV-3
|
||||||
@@ -689,19 +735,19 @@ impl DevelopSession {
|
|||||||
Some(self.inspection_point())
|
Some(self.inspection_point())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Place a square view of `extent`, keeping it inside the frame.
|
/// Place a view of `extent` (width, height), keeping it inside the frame.
|
||||||
///
|
///
|
||||||
/// Clamped rather than allowed to run off the edge: panning past the
|
/// Clamped rather than allowed to run off the edge: panning past the
|
||||||
/// boundary would show undefined area beside the photograph, which reads
|
/// boundary would show undefined area beside the photograph, which reads
|
||||||
/// as a rendering fault rather than as the end of the image.
|
/// as a rendering fault rather than as the end of the image.
|
||||||
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: (f32, f32)) {
|
||||||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
let ew = extent.0.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||||
let max = 1.0 - extent;
|
let eh = extent.1.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||||
self.graph.framing_mut().set_view(CropRect {
|
self.graph.framing_mut().set_view(CropRect {
|
||||||
x: x.clamp(0.0, max.max(0.0)),
|
x: x.clamp(0.0, (1.0 - ew).max(0.0)),
|
||||||
y: y.clamp(0.0, max.max(0.0)),
|
y: y.clamp(0.0, (1.0 - eh).max(0.0)),
|
||||||
width: extent,
|
width: ew,
|
||||||
height: extent,
|
height: eh,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +760,35 @@ impl DevelopSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// The view at `zoom`, as fractions of the `framed` image, shaped to fill
|
||||||
|
/// `viewport`.
|
||||||
|
///
|
||||||
|
/// **The view takes the viewport's shape, not the photograph's.** It used to
|
||||||
|
/// be the same fraction of each axis, so it kept the frame's aspect at every
|
||||||
|
/// zoom: a portrait photograph zoomed in on a landscape screen stayed a
|
||||||
|
/// portrait strip with the screen's sides empty, showing less of the frame
|
||||||
|
/// than the screen had room for. Here each axis shows as much of the frame
|
||||||
|
/// as the viewport holds at this magnification, capped at the whole frame —
|
||||||
|
/// so zooming a portrait widens it until it meets the screen's sides, and
|
||||||
|
/// from there both axes close in together.
|
||||||
|
///
|
||||||
|
/// `zoom` is relative to fit, as [`DevelopSession::zoom`] reports it: the
|
||||||
|
/// axis that limited the fit is the one that shows exactly `1/zoom`. Only
|
||||||
|
/// the viewport's ratio matters, not its size.
|
||||||
|
pub(super) fn view_extents(framed: (u32, u32), zoom: f32, viewport: (u32, u32)) -> (f32, f32) {
|
||||||
|
if zoom <= 1.0 {
|
||||||
|
return (1.0, 1.0);
|
||||||
|
}
|
||||||
|
let (fw, fh) = (framed.0.max(1) as f32, framed.1.max(1) as f32);
|
||||||
|
let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32);
|
||||||
|
// Screen pixels per framed pixel at this zoom.
|
||||||
|
let scale = zoom * (vw / fw).min(vh / fh);
|
||||||
|
let extent =
|
||||||
|
|screen: f32, frame: f32| (screen / (frame * scale)).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||||
|
(extent(vw, fw), extent(vh, fh))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -856,7 +931,7 @@ mod tests {
|
|||||||
let Some(ctx) = headless() else { return };
|
let Some(ctx) = headless() else { return };
|
||||||
let (mut session, _) = grey_session(&ctx);
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
|
||||||
session.zoom_about(3.0, 0.5, 0.5);
|
session.zoom_about(3.0, 0.5, 0.5, 64, 64);
|
||||||
assert!(session.is_zoomed(), "the premise");
|
assert!(session.is_zoomed(), "the premise");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -919,7 +994,7 @@ mod tests {
|
|||||||
.expect("session");
|
.expect("session");
|
||||||
|
|
||||||
let fitted = session.render(64, 64).expect("fitted render");
|
let fitted = session.render(64, 64).expect("fitted render");
|
||||||
session.zoom_about(4.0, 0.5, 0.5);
|
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
|
||||||
assert!(session.is_zoomed(), "the session did not register the zoom");
|
assert!(session.is_zoomed(), "the session did not register the zoom");
|
||||||
let zoomed = session.render(64, 64).expect("zoomed render");
|
let zoomed = session.render(64, 64).expect("zoomed render");
|
||||||
|
|
||||||
@@ -963,12 +1038,12 @@ mod tests {
|
|||||||
!session.magnifies_source(200, 200),
|
!session.magnifies_source(200, 200),
|
||||||
"a downscaled image is not magnified"
|
"a downscaled image is not magnified"
|
||||||
);
|
);
|
||||||
session.zoom_about(2.0, 0.5, 0.5);
|
session.zoom_about(2.0, 0.5, 0.5, 64, 64);
|
||||||
assert!(
|
assert!(
|
||||||
!session.magnifies_source(200, 200),
|
!session.magnifies_source(200, 200),
|
||||||
"2x on a 4x-downscaled source is still below 1:1"
|
"2x on a 4x-downscaled source is still below 1:1"
|
||||||
);
|
);
|
||||||
session.zoom_about(8.0, 0.5, 0.5);
|
session.zoom_about(8.0, 0.5, 0.5, 64, 64);
|
||||||
assert!(
|
assert!(
|
||||||
session.magnifies_source(200, 200),
|
session.magnifies_source(200, 200),
|
||||||
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
||||||
@@ -1022,7 +1097,7 @@ mod tests {
|
|||||||
|
|
||||||
// At 4× only sixteen are behind it, and sixteen are what is rendered.
|
// At 4× only sixteen are behind it, and sixteen are what is rendered.
|
||||||
session.reset_zoom();
|
session.reset_zoom();
|
||||||
session.zoom_about(4.0, 0.5, 0.5);
|
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
|
||||||
let magnified = session.render(32, 32).expect("magnified render");
|
let magnified = session.render(32, 32).expect("magnified render");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(magnified.size().width, magnified.size().height),
|
(magnified.size().width, magnified.size().height),
|
||||||
@@ -1032,6 +1107,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// A zoomed view takes the viewport's shape, capped at the whole frame.
|
||||||
|
///
|
||||||
|
/// A 2:3 portrait on a 16:9 screen: at 2× its width still fits across, so
|
||||||
|
/// the view is the full width and half the height; by 4× the width no
|
||||||
|
/// longer fits and the view is a 16:9 window onto the frame.
|
||||||
|
#[test]
|
||||||
|
fn a_zoomed_view_is_the_shape_of_the_viewport() {
|
||||||
|
let (framed, viewport) = ((2000, 3000), (1600, 900));
|
||||||
|
assert_eq!(view_extents(framed, 1.0, viewport), (1.0, 1.0));
|
||||||
|
|
||||||
|
let (w, h) = view_extents(framed, 2.0, viewport);
|
||||||
|
assert_eq!(
|
||||||
|
w, 1.0,
|
||||||
|
"at 2x a portrait's whole width fits on a landscape screen"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(h - 0.5).abs() < 1e-6,
|
||||||
|
"the limiting axis shows 1/zoom, got {h}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (w, h) = view_extents(framed, 4.0, viewport);
|
||||||
|
assert!((h - 0.25).abs() < 1e-6);
|
||||||
|
let shown = (2000.0 * w) / (3000.0 * h);
|
||||||
|
assert!(
|
||||||
|
(shown - 1600.0 / 900.0).abs() < 1e-3,
|
||||||
|
"at 4x the view should be 16:9, got {shown}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// Zooming a portrait photograph on a landscape canvas renders in the
|
||||||
|
/// canvas's shape rather than letterboxing.
|
||||||
|
///
|
||||||
|
/// The fault this guards: the view kept the photograph's aspect at every
|
||||||
|
/// zoom, so the render stayed a portrait strip and the sides of the screen
|
||||||
|
/// stayed empty however far in the photographer went.
|
||||||
|
#[test]
|
||||||
|
fn zooming_a_portrait_fills_a_landscape_viewport() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (w, h) = (40u32, 60u32);
|
||||||
|
let rgba = vec![128u8; (w * h * 4) as usize];
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
let (vw, vh) = (64u32, 36u32);
|
||||||
|
|
||||||
|
let fitted = session.render(vw, vh).expect("fitted render");
|
||||||
|
assert!(
|
||||||
|
fitted.size().width < fitted.size().height,
|
||||||
|
"the premise: fitted, a portrait is a portrait"
|
||||||
|
);
|
||||||
|
|
||||||
|
session.zoom_about(4.0, 0.5, 0.5, vw, vh);
|
||||||
|
assert!(
|
||||||
|
(session.zoom() - 4.0).abs() < 1e-3,
|
||||||
|
"zoom is {}",
|
||||||
|
session.zoom()
|
||||||
|
);
|
||||||
|
let zoomed = session.render(vw, vh).expect("zoomed render");
|
||||||
|
let shown = zoomed.size().width as f32 / zoomed.size().height as f32;
|
||||||
|
assert!(
|
||||||
|
(shown - vw as f32 / vh as f32).abs() < 0.1,
|
||||||
|
"a 4x view of a portrait on a 16:9 canvas should be 16:9, got {}x{}",
|
||||||
|
zoomed.size().width,
|
||||||
|
zoomed.size().height
|
||||||
|
);
|
||||||
|
|
||||||
|
// A canvas reshaped under a zoomed view re-cuts it, at the same zoom.
|
||||||
|
session.shape_view_to(36, 64);
|
||||||
|
assert!((session.zoom() - 4.0).abs() < 1e-3);
|
||||||
|
let turned = session.render(36, 64).expect("reshaped render");
|
||||||
|
assert!(turned.size().width < turned.size().height);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-20
|
/// TRACES: FR-DEV-20
|
||||||
/// The keystone sliders are edits with a history, a reset, and a crop
|
/// The keystone sliders are edits with a history, a reset, and a crop
|
||||||
/// that follows them.
|
/// that follows them.
|
||||||
|
|||||||
@@ -860,7 +860,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_, _, full_w, full_h) = session.overlay_clip();
|
let (_, _, full_w, full_h) = session.overlay_clip();
|
||||||
session.zoom_about(4.0, 0.5, 0.5);
|
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
|
||||||
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
|
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -878,7 +878,7 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
session.zoom_about(4.0, 0.5, 0.5);
|
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
|
||||||
let (before_x, _, _, _) = session.overlay_clip();
|
let (before_x, _, _, _) = session.overlay_clip();
|
||||||
session.pan_by(0.3, 0.0);
|
session.pan_by(0.3, 0.0);
|
||||||
let (after_x, _, _, _) = session.overlay_clip();
|
let (after_x, _, _, _) = session.overlay_clip();
|
||||||
|
|||||||
@@ -108,21 +108,28 @@ pub(super) fn shows_source_pixels(magnification: f64) -> bool {
|
|||||||
/// pixels an export would have, and leaves the enlargement to it, which draws
|
/// pixels an export would have, and leaves the enlargement to it, which draws
|
||||||
/// them nearest-neighbour; it is also a fraction of the shading.
|
/// them nearest-neighbour; it is also a fraction of the shading.
|
||||||
///
|
///
|
||||||
/// Below 1:1 this is [`fit`] of the whole frame, as it always was: the view
|
/// Below 1:1 this is [`fit`] of the *viewed region*: the view rect shrinking
|
||||||
/// rect shrinking while the target keeps its size is how a zoom short of 1:1
|
/// while the target keeps its size is how a zoom short of 1:1 gains detail.
|
||||||
/// gains detail. The two branches meet at 1:1, where both are the viewport.
|
/// The region rather than the whole frame, because a zoomed view takes the
|
||||||
|
/// viewport's shape (see `framing::view_extents`), and a render in the
|
||||||
|
/// frame's shape would letterbox it straight back. The two branches meet at
|
||||||
|
/// 1:1, where both are the viewport.
|
||||||
pub(super) fn render_size(
|
pub(super) fn render_size(
|
||||||
framed: (u32, u32),
|
framed: (u32, u32),
|
||||||
view: (f32, f32),
|
view: (f32, f32),
|
||||||
viewport: (u32, u32),
|
viewport: (u32, u32),
|
||||||
) -> (u32, u32) {
|
) -> (u32, u32) {
|
||||||
if framed.0 == 0 || framed.1 == 0 || magnification(framed, view, viewport) < 1.0 {
|
if framed.0 == 0 || framed.1 == 0 {
|
||||||
return fit(framed.0, framed.1, viewport.0.max(1), viewport.1.max(1));
|
return fit(framed.0, framed.1, viewport.0.max(1), viewport.1.max(1));
|
||||||
}
|
}
|
||||||
let behind = |edge: u32, fraction: f32| {
|
let behind = |edge: u32, fraction: f32| {
|
||||||
((f64::from(edge) * f64::from(fraction.clamp(f32::EPSILON, 1.0))).round() as u32).max(1)
|
((f64::from(edge) * f64::from(fraction.clamp(f32::EPSILON, 1.0))).round() as u32).max(1)
|
||||||
};
|
};
|
||||||
(behind(framed.0, view.0), behind(framed.1, view.1))
|
let region = (behind(framed.0, view.0), behind(framed.1, view.1));
|
||||||
|
if magnification(framed, view, viewport) < 1.0 {
|
||||||
|
return fit(region.0, region.1, viewport.0.max(1), viewport.1.max(1));
|
||||||
|
}
|
||||||
|
region
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevelopSession {
|
impl DevelopSession {
|
||||||
@@ -1609,6 +1616,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -1862,6 +1870,7 @@ mod tests {
|
|||||||
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ pub struct DevelopSession {
|
|||||||
/// satisfies, and this says whether a *measurement* was found. Only the
|
/// satisfies, and this says whether a *measurement* was found. Only the
|
||||||
/// second can honestly caption "no profile".
|
/// second can honestly caption "no profile".
|
||||||
pub(super) lens_profile_found: bool,
|
pub(super) lens_profile_found: bool,
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The camera profile embedded in the file, where its embed policy lets
|
||||||
|
/// it be copied and no installed profile already covers the body: what
|
||||||
|
/// the info panel offers to save for every photograph from that camera
|
||||||
|
/// (D20). `None` once taken up.
|
||||||
|
pub(super) profile_offer: Option<Arc<dr_decode::dcp::Dcp>>,
|
||||||
/// Kept so the session can build GPU resources after construction.
|
/// Kept so the session can build GPU resources after construction.
|
||||||
///
|
///
|
||||||
/// The distance fields behind a subject mask are made when a layer is
|
/// The distance fields behind a subject mask are made when a layer is
|
||||||
@@ -423,6 +429,7 @@ impl DevelopSession {
|
|||||||
// Nothing has been looked up, which is not the same as "looked up
|
// Nothing has been looked up, which is not the same as "looked up
|
||||||
// and not found" — `lens_summary` distinguishes them.
|
// and not found" — `lens_summary` distinguishes them.
|
||||||
lens_profile_found: false,
|
lens_profile_found: false,
|
||||||
|
profile_offer: None,
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
graph,
|
graph,
|
||||||
history,
|
history,
|
||||||
@@ -580,6 +587,74 @@ impl DevelopSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Remember the profile embedded in the file, if it may be offered for
|
||||||
|
/// copying (see [`Self::profile_offer`]).
|
||||||
|
pub fn set_embedded_profile(&mut self, embedded: Option<dr_decode::dcp::Dcp>) {
|
||||||
|
self.profile_offer = embedded
|
||||||
|
.filter(|p| p.may_copy())
|
||||||
|
.filter(|p| {
|
||||||
|
let model = p.unique_camera_model.as_deref();
|
||||||
|
model.is_some() && dr_decode::dcp::find(model, "", "").is_none()
|
||||||
|
})
|
||||||
|
.map(Arc::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// What to tell the photographer about the camera profile (D20).
|
||||||
|
///
|
||||||
|
/// Empty for an already-rendered source — a JPEG has no camera profile
|
||||||
|
/// to speak of. Otherwise the profile's name and where it came from, or
|
||||||
|
/// that there is none, which is the ordinary case for a CR2 and must read
|
||||||
|
/// as a fact: the matrix alone is a correct rendering, only a plainer one.
|
||||||
|
pub fn profile_summary(&self) -> String {
|
||||||
|
if self.demosaiced.is_non_linear() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let Some(tables) = self.demosaiced.profile_tables() else {
|
||||||
|
return "No camera profile · matrix only".into();
|
||||||
|
};
|
||||||
|
let name = if tables.name.is_empty() {
|
||||||
|
"Camera profile"
|
||||||
|
} else {
|
||||||
|
tables.name.as_str()
|
||||||
|
};
|
||||||
|
let applied = self
|
||||||
|
.graph
|
||||||
|
.param(
|
||||||
|
dr_pipeline::ops::camera_profile::ID,
|
||||||
|
dr_pipeline::ops::camera_profile::APPLY,
|
||||||
|
)
|
||||||
|
.is_none_or(|v| v != 0.0);
|
||||||
|
if !applied {
|
||||||
|
return format!("{name} · off");
|
||||||
|
}
|
||||||
|
match &tables.origin {
|
||||||
|
dr_types::ProfileOrigin::Embedded => format!("{name} · in the file"),
|
||||||
|
dr_types::ProfileOrigin::File(file) => format!("{name} · {file}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The action the info panel offers for this file's embedded profile,
|
||||||
|
/// worded with the body it would apply to; `None` where there is nothing
|
||||||
|
/// to offer.
|
||||||
|
pub fn profile_offer(&self) -> Option<String> {
|
||||||
|
let model = self.profile_offer.as_ref()?.unique_camera_model.clone()?;
|
||||||
|
Some(format!("Use this profile for every {model}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Save the embedded profile into the profiles directory, so every other
|
||||||
|
/// photograph from this body — its CR2s, above all — renders through it
|
||||||
|
/// from the next time it is opened.
|
||||||
|
pub fn adopt_profile(&mut self) -> Result<std::path::PathBuf, String> {
|
||||||
|
let profile = self.profile_offer.as_ref().ok_or("nothing to adopt")?;
|
||||||
|
let path = dr_decode::dcp::save(profile)?;
|
||||||
|
self.profile_offer = None;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-EXP-8
|
/// TRACES: FR-EXP-8
|
||||||
/// The header this session was opened from, where there was one.
|
/// The header this session was opened from, where there was one.
|
||||||
///
|
///
|
||||||
@@ -733,6 +808,107 @@ mod tests {
|
|||||||
/// own rule is that the interface must be plain about which it is, because
|
/// own rule is that the interface must be plain about which it is, because
|
||||||
/// a correction that silently did nothing is worse than one visibly
|
/// a correction that silently did nothing is worse than one visibly
|
||||||
/// unavailable.
|
/// unavailable.
|
||||||
|
/// A flat 8×8 raw carrying `tables`, for the camera profile line.
|
||||||
|
fn raw_with(tables: Option<dr_types::ProfileTables>) -> dr_decode::RawImage {
|
||||||
|
dr_decode::RawImage {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
data: vec![20_000; 64],
|
||||||
|
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||||
|
black_level: [0; 4],
|
||||||
|
white_level: u16::MAX,
|
||||||
|
wb_coeffs: [1.0; 4],
|
||||||
|
color_matrix: None,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
profile_tables: tables.map(Arc::new),
|
||||||
|
make: "Canon".into(),
|
||||||
|
model: "EOS 6D".into(),
|
||||||
|
crop: dr_decode::CropRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_profile_line_names_the_profile_and_where_it_came_from() {
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let tables = |origin| dr_types::ProfileTables {
|
||||||
|
name: "Adobe Standard".into(),
|
||||||
|
origin,
|
||||||
|
hue_sat: None,
|
||||||
|
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||||
|
};
|
||||||
|
let open = |t| {
|
||||||
|
DevelopSession::open(&ctx, &raw_with(t), dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session")
|
||||||
|
};
|
||||||
|
|
||||||
|
let none = open(None);
|
||||||
|
assert_eq!(none.profile_summary(), "No camera profile · matrix only");
|
||||||
|
assert_eq!(none.profile_offer(), None);
|
||||||
|
|
||||||
|
let mut embedded = open(Some(tables(dr_types::ProfileOrigin::Embedded)));
|
||||||
|
assert_eq!(embedded.profile_summary(), "Adobe Standard · in the file");
|
||||||
|
embedded.graph.set_param(
|
||||||
|
dr_pipeline::ops::camera_profile::ID,
|
||||||
|
dr_pipeline::ops::camera_profile::APPLY,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
assert_eq!(embedded.profile_summary(), "Adobe Standard · off");
|
||||||
|
|
||||||
|
let file = open(Some(tables(dr_types::ProfileOrigin::File(
|
||||||
|
"Canon EOS 6D Adobe Standard.dcp".into(),
|
||||||
|
))));
|
||||||
|
assert_eq!(
|
||||||
|
file.profile_summary(),
|
||||||
|
"Adobe Standard · Canon EOS 6D Adobe Standard.dcp"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A JPEG has no camera profile to speak of.
|
||||||
|
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||||
|
let jpeg = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
assert_eq!(jpeg.profile_summary(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_copyable_profile_is_offered() {
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let mut s = DevelopSession::open(&ctx, &raw_with(None), dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
let profile = |policy| dr_decode::dcp::Dcp {
|
||||||
|
name: "Adobe Standard".into(),
|
||||||
|
unique_camera_model: Some("Nonexistent Body 1".into()),
|
||||||
|
copyright: None,
|
||||||
|
calibration_signature: None,
|
||||||
|
embed_policy: policy,
|
||||||
|
illuminants: [Some(21), None],
|
||||||
|
color_matrix: [
|
||||||
|
Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
forward_matrix: [None, None],
|
||||||
|
hue_sat: [None, None],
|
||||||
|
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||||
|
tone_curve: None,
|
||||||
|
};
|
||||||
|
s.set_embedded_profile(Some(profile(0)));
|
||||||
|
assert_eq!(
|
||||||
|
s.profile_offer().as_deref(),
|
||||||
|
Some("Use this profile for every Nonexistent Body 1")
|
||||||
|
);
|
||||||
|
s.set_embedded_profile(Some(profile(2)));
|
||||||
|
assert_eq!(s.profile_offer(), None, "embed never");
|
||||||
|
s.set_embedded_profile(None);
|
||||||
|
assert_eq!(s.profile_offer(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
||||||
let Some(ctx) = headless() else { return };
|
let Some(ctx) = headless() else { return };
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ mod tests {
|
|||||||
color_matrix: Some(cam_to_srgb),
|
color_matrix: Some(cam_to_srgb),
|
||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -305,6 +306,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -357,6 +359,7 @@ mod tests {
|
|||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
|
profile_tables: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
|
|||||||
@@ -293,6 +293,11 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
|||||||
request.settings.target = target;
|
request.settings.target = target;
|
||||||
request.settings.destination = folder;
|
request.settings.destination = folder;
|
||||||
request.images = images;
|
request.images = images;
|
||||||
|
if target == dr_types::ExportTarget::Remote {
|
||||||
|
if let Some(a) = albums.as_ref() {
|
||||||
|
request.remote_names = a.file_names(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let token = export::Cancel::default();
|
let token = export::Cancel::default();
|
||||||
*cancel.borrow_mut() = token.clone();
|
*cancel.borrow_mut() = token.clone();
|
||||||
@@ -315,12 +320,14 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
|||||||
total,
|
total,
|
||||||
to,
|
to,
|
||||||
move |written| {
|
move |written| {
|
||||||
drain_outbox(&library_for_drain, weak.clone());
|
|
||||||
// What the album now holds, and which photograph
|
// What the album now holds, and which photograph
|
||||||
// each file came from.
|
// each file came from. Before the drain starts: an
|
||||||
|
// upload that finds a name taken on the server
|
||||||
|
// renames the album's row, which must be there.
|
||||||
if let (Some(albums), Some(w)) = (albums.as_ref(), weak.upgrade()) {
|
if let (Some(albums), Some(w)) = (albums.as_ref(), weak.upgrade()) {
|
||||||
albums.record(&w, album, written);
|
albums.record(&w, album, written);
|
||||||
}
|
}
|
||||||
|
drain_outbox(&library_for_drain, weak.clone());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -888,10 +895,12 @@ fn wire_zoom_pan_crop(
|
|||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let redraw = redraw.clone();
|
let redraw = redraw.clone();
|
||||||
|
let viewport = viewport.clone();
|
||||||
window.on_zoom_at(move |factor, at_x, at_y| {
|
window.on_zoom_at(move |factor, at_x, at_y| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let (vw, vh) = *viewport.borrow();
|
||||||
if let Some(s) = session.borrow_mut().as_mut() {
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
s.zoom_about(factor, at_x, at_y);
|
s.zoom_about(factor, at_x, at_y, vw, vh);
|
||||||
}
|
}
|
||||||
redraw(&w);
|
redraw(&w);
|
||||||
});
|
});
|
||||||
@@ -1042,9 +1051,10 @@ fn wire_zoom_pan_crop(
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
};
|
};
|
||||||
// The overlay reports the corner it is *holding*; the point
|
// The overlay reports the handle it is *holding*; the point
|
||||||
// that must not move is the opposite one. A move reports no
|
// that must not move is the opposite one — the far corner, or
|
||||||
// corner at all, and keeps the shape it already has — there is
|
// for an edge the middle of the far side. A move reports no
|
||||||
|
// handle at all, and keeps the shape it already has — there is
|
||||||
// nothing to reshape, and reshaping about a centre would drag
|
// nothing to reshape, and reshaping about a centre would drag
|
||||||
// an over-moved rect smaller instead of sliding it along the
|
// an over-moved rect smaller instead of sliding it along the
|
||||||
// edge.
|
// edge.
|
||||||
|
|||||||
+422
-22
@@ -56,7 +56,7 @@
|
|||||||
//! allow (FR-EXP-8).
|
//! allow (FR-EXP-8).
|
||||||
|
|
||||||
use crate::executors::{self, Executor};
|
use crate::executors::{self, Executor};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -67,7 +67,7 @@ use std::time::Duration;
|
|||||||
use dr_export::{Encoded, NameContext};
|
use dr_export::{Encoded, NameContext};
|
||||||
use dr_sync::RemotePath;
|
use dr_sync::RemotePath;
|
||||||
use dr_sync::{Account, Connection};
|
use dr_sync::{Account, Connection};
|
||||||
use dr_types::{ExportSettings, ExportTarget};
|
use dr_types::{CollisionPolicy, ExportSettings, ExportTarget};
|
||||||
use slint::ComponentHandle as _;
|
use slint::ComponentHandle as _;
|
||||||
|
|
||||||
use crate::{AppWindow, Library};
|
use crate::{AppWindow, Library};
|
||||||
@@ -106,6 +106,13 @@ pub struct Pending {
|
|||||||
pub account: bool,
|
pub account: bool,
|
||||||
/// The filename to give it there.
|
/// The filename to give it there.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// What to do if the server already holds `name`. Decided when the
|
||||||
|
/// export was made, applied when it uploads: the batch could not see
|
||||||
|
/// the server, so the name it chose is a request, not a fact. `None`
|
||||||
|
/// for a record written before the policy travelled with it, and for
|
||||||
|
/// a merge's composite, which the catalog has already recorded under
|
||||||
|
/// its name — both are sent as named.
|
||||||
|
pub collision: Option<CollisionPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pending {
|
impl Pending {
|
||||||
@@ -156,6 +163,7 @@ pub(crate) fn staged_remote_path(root: &str, remote_dir: &str, name: &str) -> Re
|
|||||||
remote_dir: remote_dir.to_string(),
|
remote_dir: remote_dir.to_string(),
|
||||||
account: false,
|
account: false,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
collision: None,
|
||||||
}
|
}
|
||||||
.remote_path(root)
|
.remote_path(root)
|
||||||
}
|
}
|
||||||
@@ -222,6 +230,7 @@ pub fn place(
|
|||||||
target: ExportTarget,
|
target: ExportTarget,
|
||||||
destination: &str,
|
destination: &str,
|
||||||
outbox: &Path,
|
outbox: &Path,
|
||||||
|
collision: CollisionPolicy,
|
||||||
) -> Result<Placed, String> {
|
) -> Result<Placed, String> {
|
||||||
match target {
|
match target {
|
||||||
ExportTarget::Device => {
|
ExportTarget::Device => {
|
||||||
@@ -251,7 +260,7 @@ pub fn place(
|
|||||||
Ok(Placed::Device(path))
|
Ok(Placed::Device(path))
|
||||||
}
|
}
|
||||||
ExportTarget::Remote => {
|
ExportTarget::Remote => {
|
||||||
let local = stage(encoded, destination, outbox)?;
|
let local = stage(encoded, destination, outbox, collision)?;
|
||||||
Ok(Placed::Queued {
|
Ok(Placed::Queued {
|
||||||
local,
|
local,
|
||||||
remote_dir: destination.to_string(),
|
remote_dir: destination.to_string(),
|
||||||
@@ -286,7 +295,12 @@ fn mime_for(name: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write bytes and their destination record into the outbox.
|
/// Write bytes and their destination record into the outbox.
|
||||||
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> {
|
fn stage(
|
||||||
|
encoded: &Encoded,
|
||||||
|
remote_dir: &str,
|
||||||
|
outbox: &Path,
|
||||||
|
collision: CollisionPolicy,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||||
|
|
||||||
// The staged name is the export's name, deduplicated against the outbox
|
// The staged name is the export's name, deduplicated against the outbox
|
||||||
@@ -323,11 +337,18 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
|
|||||||
// strings do not need a parser, and a format a human can repair by hand
|
// strings do not need a parser, and a format a human can repair by hand
|
||||||
// is worth something for a queue holding the only copy of someone's work.
|
// is worth something for a queue holding the only copy of someone's work.
|
||||||
// A folder spelled from `/` is an album's, relative to the account; it
|
// A folder spelled from `/` is an album's, relative to the account; it
|
||||||
// is recorded as such on a third line — see `Pending::account`.
|
// is recorded as such on a third line — see `Pending::account`. The
|
||||||
let text = match remote_dir.strip_prefix('/') {
|
// fourth is the collision policy, which an older build stops before
|
||||||
Some(dir) => format!("{dir}\n{}\naccount\n", encoded.name),
|
// reading and so uploads as named, as it always did.
|
||||||
None => format!("{remote_dir}\n{}\n", encoded.name),
|
let (dir, base) = match remote_dir.strip_prefix('/') {
|
||||||
|
Some(dir) => (dir, "account"),
|
||||||
|
None => (remote_dir, "library"),
|
||||||
};
|
};
|
||||||
|
let text = format!(
|
||||||
|
"{dir}\n{}\n{base}\n{}\n",
|
||||||
|
encoded.name,
|
||||||
|
policy_word(collision)
|
||||||
|
);
|
||||||
std::fs::write(&record, text).map_err(|e| format!("{}: {e}", record.display()))?;
|
std::fs::write(&record, text).map_err(|e| format!("{}: {e}", record.display()))?;
|
||||||
|
|
||||||
Ok(candidate)
|
Ok(candidate)
|
||||||
@@ -442,6 +463,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
|
|||||||
let remote_dir = lines.next().unwrap_or("").to_string();
|
let remote_dir = lines.next().unwrap_or("").to_string();
|
||||||
let name = lines.next().unwrap_or("").to_string();
|
let name = lines.next().unwrap_or("").to_string();
|
||||||
let account = lines.next() == Some("account");
|
let account = lines.next() == Some("account");
|
||||||
|
let collision = lines.next().and_then(policy_from_word);
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -450,6 +472,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
|
|||||||
remote_dir,
|
remote_dir,
|
||||||
name,
|
name,
|
||||||
account,
|
account,
|
||||||
|
collision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Stable order so a drain is reproducible and a stuck entry is obvious
|
// Stable order so a drain is reproducible and a stuck entry is obvious
|
||||||
@@ -458,6 +481,24 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A collision policy as an outbox record spells it.
|
||||||
|
fn policy_word(policy: CollisionPolicy) -> &'static str {
|
||||||
|
match policy {
|
||||||
|
CollisionPolicy::Overwrite => "overwrite",
|
||||||
|
CollisionPolicy::Skip => "skip",
|
||||||
|
CollisionPolicy::Increment => "increment",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_from_word(word: &str) -> Option<CollisionPolicy> {
|
||||||
|
match word {
|
||||||
|
"overwrite" => Some(CollisionPolicy::Overwrite),
|
||||||
|
"skip" => Some(CollisionPolicy::Skip),
|
||||||
|
"increment" => Some(CollisionPolicy::Increment),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How many exports are waiting. For the interface to show, and cheap enough
|
/// How many exports are waiting. For the interface to show, and cheap enough
|
||||||
/// to call on a redraw.
|
/// to call on a redraw.
|
||||||
pub fn pending_count(outbox: &Path) -> usize {
|
pub fn pending_count(outbox: &Path) -> usize {
|
||||||
@@ -543,14 +584,23 @@ enum Sent {
|
|||||||
Uploaded,
|
Uploaded,
|
||||||
/// Its payload had gone; the record went with it.
|
/// Its payload had gone; the record went with it.
|
||||||
Gone,
|
Gone,
|
||||||
|
/// The server held its name and the policy was Skip.
|
||||||
|
Skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What each destination folder holds, as far as this drain knows: listed
|
||||||
|
/// once on the first entry bound for it, and added to as files land. One
|
||||||
|
/// request per folder per drain rather than one per file — a batch of
|
||||||
|
/// three hundred into one album is one listing.
|
||||||
|
type Listings = HashMap<String, HashSet<String>>;
|
||||||
|
|
||||||
/// Send one outbox entry, and clear it once it is on the server.
|
/// Send one outbox entry, and clear it once it is on the server.
|
||||||
async fn send(
|
async fn send(
|
||||||
backend: &dyn dr_sync::RemoteBackend,
|
backend: &dyn dr_sync::RemoteBackend,
|
||||||
library: &LibraryFiles,
|
library: &LibraryFiles,
|
||||||
root: &str,
|
root: &str,
|
||||||
entry: &Pending,
|
entry: &Pending,
|
||||||
|
listings: &mut Listings,
|
||||||
) -> Result<Sent, String> {
|
) -> Result<Sent, String> {
|
||||||
let Ok(bytes) = std::fs::read(&entry.local) else {
|
let Ok(bytes) = std::fs::read(&entry.local) else {
|
||||||
// The payload vanished under us. Drop the record too; retrying
|
// The payload vanished under us. Drop the record too; retrying
|
||||||
@@ -562,20 +612,85 @@ async fn send(
|
|||||||
// The folder may not exist — this is the first export into it — and
|
// The folder may not exist — this is the first export into it — and
|
||||||
// `create_dir` treats "already there" as success, so it is unconditional
|
// `create_dir` treats "already there" as success, so it is unconditional
|
||||||
// rather than guarded by a check that would cost a request every time.
|
// rather than guarded by a check that would cost a request every time.
|
||||||
|
let folder = entry.remote_folder(root);
|
||||||
backend
|
backend
|
||||||
.create_dir(&entry.remote_folder(root))
|
.create_dir(&folder)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// TRACES: FR-EXP-6
|
||||||
|
// The batch named this file without seeing the server, so the policy it
|
||||||
|
// was made under is applied here, against what the folder holds now.
|
||||||
|
// Overwrite, and a record that carries no policy, put it as named.
|
||||||
|
let names = match entry.collision {
|
||||||
|
Some(CollisionPolicy::Increment | CollisionPolicy::Skip) => {
|
||||||
|
let key = folder.as_str().to_string();
|
||||||
|
if !listings.contains_key(&key) {
|
||||||
|
let listed = backend
|
||||||
|
.list(&folder, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let held = listed.iter().map(|e| e.path.name().to_string()).collect();
|
||||||
|
listings.insert(key.clone(), held);
|
||||||
|
}
|
||||||
|
listings.get_mut(&key)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut renamed = None;
|
||||||
|
if let Some(held) = names.as_deref() {
|
||||||
|
if held.contains(&entry.name) {
|
||||||
|
if entry.collision == Some(CollisionPolicy::Skip) {
|
||||||
|
log::info!("upload: {} is already on the server; skipped", entry.name);
|
||||||
|
clear(entry);
|
||||||
|
return Ok(Sent::Skipped);
|
||||||
|
}
|
||||||
|
let free = step_past(&entry.name, &|n| held.contains(n))
|
||||||
|
.ok_or_else(|| format!("{}: ten thousand names taken", entry.name))?;
|
||||||
|
renamed = Some(Pending {
|
||||||
|
name: free,
|
||||||
|
..entry.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sent = renamed.as_ref().unwrap_or(entry);
|
||||||
|
|
||||||
backend
|
backend
|
||||||
.put(&entry.remote_path(root), bytes, None)
|
.put(&sent.remote_path(root), bytes, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(held) = names {
|
||||||
|
held.insert(sent.name.clone());
|
||||||
|
}
|
||||||
|
if renamed.is_some() {
|
||||||
|
log::info!(
|
||||||
|
"upload: {} was taken on the server; sent as {}",
|
||||||
|
entry.name,
|
||||||
|
sent.name
|
||||||
|
);
|
||||||
|
rename_in_album(library, entry, &sent.name);
|
||||||
|
}
|
||||||
let thumbnails = take_thumbnails(&entry.local);
|
let thumbnails = take_thumbnails(&entry.local);
|
||||||
register_upload(backend, library, root, entry, thumbnails).await;
|
register_upload(backend, library, root, sent, thumbnails).await;
|
||||||
clear(entry);
|
clear(entry);
|
||||||
Ok(Sent::Uploaded)
|
Ok(Sent::Uploaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-10
|
||||||
|
/// Tell the album a file it recorded arrived under another name. Only an
|
||||||
|
/// album's entries are recorded by name; a library export is the scan's.
|
||||||
|
fn rename_in_album(library: &LibraryFiles, entry: &Pending, to: &str) {
|
||||||
|
if !entry.account {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result = dr_catalog::Catalog::open(&library.catalog).and_then(|catalog| {
|
||||||
|
dr_catalog::albums::rename_export(catalog.connection(), &entry.remote_dir, &entry.name, to)
|
||||||
|
});
|
||||||
|
if let Err(e) = result {
|
||||||
|
log::warn!("upload: recording {} as {to} in its album: {e}", entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-MRG-6
|
/// TRACES: FR-MRG-6
|
||||||
/// Tell the catalog what the server made of a file it has just been given.
|
/// Tell the catalog what the server made of a file it has just been given.
|
||||||
///
|
///
|
||||||
@@ -703,6 +818,7 @@ pub fn spawn_upload(
|
|||||||
let mut uploaded = 0;
|
let mut uploaded = 0;
|
||||||
let mut landed = 0;
|
let mut landed = 0;
|
||||||
let mut error = None;
|
let mut error = None;
|
||||||
|
let mut listings = Listings::new();
|
||||||
|
|
||||||
for (i, entry) in queue.iter().enumerate() {
|
for (i, entry) in queue.iter().enumerate() {
|
||||||
let _ = tx.send(UploadMessage::Status(format!(
|
let _ = tx.send(UploadMessage::Status(format!(
|
||||||
@@ -711,8 +827,8 @@ pub fn spawn_upload(
|
|||||||
i + 1
|
i + 1
|
||||||
)));
|
)));
|
||||||
|
|
||||||
match send(&*backend, &library, &root, entry).await {
|
match send(&*backend, &library, &root, entry, &mut listings).await {
|
||||||
Ok(Sent::Gone) => {}
|
Ok(Sent::Gone | Sent::Skipped) => {}
|
||||||
Ok(Sent::Uploaded) => {
|
Ok(Sent::Uploaded) => {
|
||||||
uploaded += 1;
|
uploaded += 1;
|
||||||
if !entry.account {
|
if !entry.account {
|
||||||
@@ -840,6 +956,13 @@ pub struct BatchRequest {
|
|||||||
/// anything that has to be fetched.
|
/// anything that has to be fetched.
|
||||||
pub conn: Option<Connection>,
|
pub conn: Option<Connection>,
|
||||||
pub settings: ExportSettings,
|
pub settings: ExportSettings,
|
||||||
|
/// TRACES: FR-EXP-6
|
||||||
|
/// Names a server destination is known to hold — what the album records
|
||||||
|
/// of earlier exports. A queued export cannot ask the server, so this is
|
||||||
|
/// what [`resolve_batch_name`] checks instead; the upload checks the
|
||||||
|
/// server itself for anything put there some other way. Unused for a
|
||||||
|
/// device export, which looks at the folder.
|
||||||
|
pub remote_names: HashSet<String>,
|
||||||
pub outbox: PathBuf,
|
pub outbox: PathBuf,
|
||||||
pub sidecar_cache: PathBuf,
|
pub sidecar_cache: PathBuf,
|
||||||
pub offline: bool,
|
pub offline: bool,
|
||||||
@@ -933,6 +1056,13 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
|
|||||||
let mut issued: HashSet<String> = HashSet::new();
|
let mut issued: HashSet<String> = HashSet::new();
|
||||||
let (mut exported, mut failed) = (0usize, 0usize);
|
let (mut exported, mut failed) = (0usize, 0usize);
|
||||||
|
|
||||||
|
// What earlier batches queued for the same folder and has not uploaded
|
||||||
|
// yet is as much in the way as what is already there.
|
||||||
|
if request.settings.target == ExportTarget::Remote {
|
||||||
|
let names = queued_for(&request.outbox, &request.settings.destination);
|
||||||
|
request.remote_names.extend(names);
|
||||||
|
}
|
||||||
|
|
||||||
for (i, source) in sources.into_iter().enumerate() {
|
for (i, source) in sources.into_iter().enumerate() {
|
||||||
if cancel.is_cancelled() {
|
if cancel.is_cancelled() {
|
||||||
break;
|
break;
|
||||||
@@ -974,6 +1104,18 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The names waiting in the outbox for `destination`, spelled as a batch
|
||||||
|
/// spells a remote folder: from `/` for an album, relative to the library
|
||||||
|
/// otherwise.
|
||||||
|
fn queued_for(outbox: &Path, destination: &str) -> impl Iterator<Item = String> {
|
||||||
|
let account = destination.starts_with('/');
|
||||||
|
let folder = destination.trim_matches('/').to_string();
|
||||||
|
pending(outbox)
|
||||||
|
.into_iter()
|
||||||
|
.filter(move |p| p.account == account && p.remote_dir.trim_matches('/') == folder)
|
||||||
|
.map(|p| p.name)
|
||||||
|
}
|
||||||
|
|
||||||
/// One image, start to finish. `None` where the run was cancelled part way.
|
/// One image, start to finish. `None` where the run was cancelled part way.
|
||||||
fn export_one(
|
fn export_one(
|
||||||
request: &BatchRequest,
|
request: &BatchRequest,
|
||||||
@@ -1237,7 +1379,8 @@ fn place_frame(
|
|||||||
preset: "",
|
preset: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = resolve_batch_name(&request.settings, &ctx, issued).ok_or(ItemError::NameTaken)?;
|
let name = resolve_batch_name(&request.settings, &request.remote_names, &ctx, issued)
|
||||||
|
.ok_or(ItemError::NameTaken)?;
|
||||||
let encoded = dr_export::export(frame, &request.settings, name, source)?;
|
let encoded = dr_export::export(frame, &request.settings, name, source)?;
|
||||||
|
|
||||||
place(
|
place(
|
||||||
@@ -1245,6 +1388,7 @@ fn place_frame(
|
|||||||
request.settings.target,
|
request.settings.target,
|
||||||
&request.settings.destination,
|
&request.settings.destination,
|
||||||
&request.outbox,
|
&request.outbox,
|
||||||
|
request.settings.collision,
|
||||||
)
|
)
|
||||||
.map_err(ItemError::Place)
|
.map_err(ItemError::Place)
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1407,7 @@ fn place_frame(
|
|||||||
/// the folder.
|
/// the folder.
|
||||||
fn resolve_batch_name(
|
fn resolve_batch_name(
|
||||||
settings: &ExportSettings,
|
settings: &ExportSettings,
|
||||||
|
remote_names: &HashSet<String>,
|
||||||
ctx: &NameContext<'_>,
|
ctx: &NameContext<'_>,
|
||||||
issued: &mut HashSet<String>,
|
issued: &mut HashSet<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
@@ -1275,8 +1420,9 @@ fn resolve_batch_name(
|
|||||||
}
|
}
|
||||||
dr_types::ExportTarget::Device => dir.join(name).exists(),
|
dr_types::ExportTarget::Device => dir.join(name).exists(),
|
||||||
// A queued export cannot see the server, and may never be able to.
|
// A queued export cannot see the server, and may never be able to.
|
||||||
// Names are kept apart in the outbox instead — see [`stage`].
|
// What the album records stands in for it here, and the upload
|
||||||
dr_types::ExportTarget::Remote => false,
|
// checks the server itself — see [`send`].
|
||||||
|
dr_types::ExportTarget::Remote => remote_names.contains(name),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1745,9 +1891,15 @@ mod tests {
|
|||||||
let server = Assigning::default();
|
let server = Assigning::default();
|
||||||
assert_eq!(file_id_of(&s.library, s.image), None);
|
assert_eq!(file_id_of(&s.library, s.image), None);
|
||||||
|
|
||||||
let sent = send(&server, &s.library, "PhotosRaw", &s.entry)
|
let sent = send(
|
||||||
.await
|
&server,
|
||||||
.unwrap();
|
&s.library,
|
||||||
|
"PhotosRaw",
|
||||||
|
&s.entry,
|
||||||
|
&mut Listings::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(sent, Sent::Uploaded);
|
assert_eq!(sent, Sent::Uploaded);
|
||||||
|
|
||||||
// The id the server assigned, on the row the merge wrote, and the
|
// The id the server assigned, on the row the merge wrote, and the
|
||||||
@@ -1789,7 +1941,9 @@ mod tests {
|
|||||||
std::fs::create_dir_all(library_dir.join("Alps")).unwrap();
|
std::fs::create_dir_all(library_dir.join("Alps")).unwrap();
|
||||||
let folder = dr_sync_folder::FolderBackend::new(&library_dir).unwrap();
|
let folder = dr_sync_folder::FolderBackend::new(&library_dir).unwrap();
|
||||||
|
|
||||||
send(&folder, &s.library, "", &s.entry).await.unwrap();
|
send(&folder, &s.library, "", &s.entry, &mut Listings::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(library_dir.join("Alps/_MG_8320-pano.dng")).unwrap(),
|
std::fs::read(library_dir.join("Alps/_MG_8320-pano.dng")).unwrap(),
|
||||||
@@ -1823,9 +1977,163 @@ mod tests {
|
|||||||
.find(|p| p.name == "print.jpg")
|
.find(|p| p.name == "print.jpg")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let server = Assigning::default();
|
let server = Assigning::default();
|
||||||
send(&server, &s.library, "PhotosRaw", &entry)
|
send(
|
||||||
|
&server,
|
||||||
|
&s.library,
|
||||||
|
"PhotosRaw",
|
||||||
|
&entry,
|
||||||
|
&mut Listings::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(server.lists.load(Ordering::SeqCst), 0);
|
||||||
|
let _ = std::fs::remove_dir_all(&s.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An album export queued under `policy`, the album recording it, and a
|
||||||
|
/// server that already holds a file of that name.
|
||||||
|
async fn queued_over_a_taken_name(
|
||||||
|
tag: &str,
|
||||||
|
policy: CollisionPolicy,
|
||||||
|
) -> (Staged, Assigning, dr_catalog::albums::AlbumId) {
|
||||||
|
let s = staged(tag, "PhotosRaw");
|
||||||
|
let catalog = dr_catalog::Catalog::open(&s.library.catalog).unwrap();
|
||||||
|
let album = dr_catalog::albums::create(
|
||||||
|
catalog.connection(),
|
||||||
|
"Web",
|
||||||
|
&dr_catalog::albums::Place::Server("Shared/Web".into()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dr_catalog::albums::record_exports(
|
||||||
|
catalog.connection(),
|
||||||
|
album,
|
||||||
|
&[(dr_types::ImageId(s.image as u64), "a.jpg".into())],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let server = Assigning::default();
|
||||||
|
use dr_sync::RemoteBackend as _;
|
||||||
|
server
|
||||||
|
.put(
|
||||||
|
&RemotePath::new("Shared/Web/a.jpg"),
|
||||||
|
b"theirs".to_vec(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
place(
|
||||||
|
&encoded("a.jpg", b"ours"),
|
||||||
|
ExportTarget::Remote,
|
||||||
|
"/Shared/Web",
|
||||||
|
&s.dir.join("outbox"),
|
||||||
|
policy,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(s, server, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queued_named(s: &Staged, name: &str) -> Vec<Pending> {
|
||||||
|
pending(&s.dir.join("outbox"))
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.name == name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn held(server: &Assigning, path: &str) -> Option<Vec<u8>> {
|
||||||
|
server
|
||||||
|
.files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(path)
|
||||||
|
.map(|(_, body)| body.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_increment_export_steps_past_a_name_the_server_holds() {
|
||||||
|
let (s, server, album) =
|
||||||
|
queued_over_a_taken_name("increment", CollisionPolicy::Increment).await;
|
||||||
|
// A second export of the same name, queued before either uploaded.
|
||||||
|
place(
|
||||||
|
&encoded("a.jpg", b"ours too"),
|
||||||
|
ExportTarget::Remote,
|
||||||
|
"/Shared/Web",
|
||||||
|
&s.dir.join("outbox"),
|
||||||
|
CollisionPolicy::Increment,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut listings = Listings::new();
|
||||||
|
for entry in queued_named(&s, "a.jpg") {
|
||||||
|
let sent = send(&server, &s.library, "PhotosRaw", &entry, &mut listings)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sent, Sent::Uploaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(held(&server, "Shared/Web/a.jpg").unwrap(), b"theirs");
|
||||||
|
let mut ours = vec![
|
||||||
|
held(&server, "Shared/Web/a-1.jpg").unwrap(),
|
||||||
|
held(&server, "Shared/Web/a-2.jpg").unwrap(),
|
||||||
|
];
|
||||||
|
ours.sort();
|
||||||
|
assert_eq!(ours, vec![b"ours".to_vec(), b"ours too".to_vec()]);
|
||||||
|
assert_eq!(
|
||||||
|
server.lists.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"one listing for the folder, however many files go into it"
|
||||||
|
);
|
||||||
|
// The album's row followed its file to the name it was given.
|
||||||
|
let catalog = dr_catalog::Catalog::open(&s.library.catalog).unwrap();
|
||||||
|
let names = dr_catalog::albums::file_names(catalog.connection(), album).unwrap();
|
||||||
|
assert!(!names.contains("a.jpg"), "{names:?}");
|
||||||
|
assert!(
|
||||||
|
queued_named(&s, "a.jpg").is_empty(),
|
||||||
|
"the outbox is cleared"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&s.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_skip_export_leaves_a_name_the_server_holds() {
|
||||||
|
let (s, server, _) = queued_over_a_taken_name("skip", CollisionPolicy::Skip).await;
|
||||||
|
let entry = queued_named(&s, "a.jpg").pop().unwrap();
|
||||||
|
|
||||||
|
let sent = send(
|
||||||
|
&server,
|
||||||
|
&s.library,
|
||||||
|
"PhotosRaw",
|
||||||
|
&entry,
|
||||||
|
&mut Listings::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sent, Sent::Skipped);
|
||||||
|
assert_eq!(held(&server, "Shared/Web/a.jpg").unwrap(), b"theirs");
|
||||||
|
assert_eq!(server.files.lock().unwrap().len(), 1);
|
||||||
|
assert!(
|
||||||
|
queued_named(&s, "a.jpg").is_empty(),
|
||||||
|
"the outbox is cleared"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&s.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_overwrite_export_replaces_a_name_the_server_holds() {
|
||||||
|
let (s, server, _) =
|
||||||
|
queued_over_a_taken_name("overwrite", CollisionPolicy::Overwrite).await;
|
||||||
|
let entry = queued_named(&s, "a.jpg").pop().unwrap();
|
||||||
|
|
||||||
|
send(
|
||||||
|
&server,
|
||||||
|
&s.library,
|
||||||
|
"PhotosRaw",
|
||||||
|
&entry,
|
||||||
|
&mut Listings::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(held(&server, "Shared/Web/a.jpg").unwrap(), b"ours");
|
||||||
assert_eq!(server.lists.load(Ordering::SeqCst), 0);
|
assert_eq!(server.lists.load(Ordering::SeqCst), 0);
|
||||||
let _ = std::fs::remove_dir_all(&s.dir);
|
let _ = std::fs::remove_dir_all(&s.dir);
|
||||||
}
|
}
|
||||||
@@ -1878,6 +2186,7 @@ mod tests {
|
|||||||
ExportTarget::Device,
|
ExportTarget::Device,
|
||||||
target.to_str().unwrap(),
|
target.to_str().unwrap(),
|
||||||
&dir.join("outbox"),
|
&dir.join("outbox"),
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1896,6 +2205,7 @@ mod tests {
|
|||||||
ExportTarget::Device,
|
ExportTarget::Device,
|
||||||
target.to_str().unwrap(),
|
target.to_str().unwrap(),
|
||||||
&dir,
|
&dir,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.is_ok());
|
.is_ok());
|
||||||
assert!(target.join("a.jpg").exists());
|
assert!(target.join("a.jpg").exists());
|
||||||
@@ -1906,7 +2216,14 @@ mod tests {
|
|||||||
// Rather than writing to the process's working directory, which is
|
// Rather than writing to the process's working directory, which is
|
||||||
// wherever the app happened to be launched from.
|
// wherever the app happened to be launched from.
|
||||||
let dir = tmp();
|
let dir = tmp();
|
||||||
let err = place(&encoded("a.jpg", b"x"), ExportTarget::Device, " ", &dir).unwrap_err();
|
let err = place(
|
||||||
|
&encoded("a.jpg", b"x"),
|
||||||
|
ExportTarget::Device,
|
||||||
|
" ",
|
||||||
|
&dir,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
// Says what to do about it: the destination is an album now.
|
// Says what to do about it: the destination is an album now.
|
||||||
assert!(err.contains("album"), "unhelpful message: {err}");
|
assert!(err.contains("album"), "unhelpful message: {err}");
|
||||||
}
|
}
|
||||||
@@ -1922,6 +2239,7 @@ mod tests {
|
|||||||
ExportTarget::Remote,
|
ExportTarget::Remote,
|
||||||
"Exports/2026",
|
"Exports/2026",
|
||||||
&outbox,
|
&outbox,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1947,6 +2265,7 @@ mod tests {
|
|||||||
ExportTarget::Remote,
|
ExportTarget::Remote,
|
||||||
"Exports",
|
"Exports",
|
||||||
&outbox,
|
&outbox,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1967,6 +2286,7 @@ mod tests {
|
|||||||
ExportTarget::Remote,
|
ExportTarget::Remote,
|
||||||
"E",
|
"E",
|
||||||
&outbox,
|
&outbox,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
place(
|
place(
|
||||||
@@ -1974,6 +2294,7 @@ mod tests {
|
|||||||
ExportTarget::Remote,
|
ExportTarget::Remote,
|
||||||
"E",
|
"E",
|
||||||
&outbox,
|
&outbox,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1985,6 +2306,79 @@ mod tests {
|
|||||||
assert_ne!(queue[0].local, queue[1].local);
|
assert_ne!(queue[0].local, queue[1].local);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remote_batch_names_around_the_album_and_the_outbox() {
|
||||||
|
let dir = tmp();
|
||||||
|
let outbox = dir.join("outbox");
|
||||||
|
// Queued by an earlier batch, not uploaded yet.
|
||||||
|
place(
|
||||||
|
&encoded("IMG_0001-1.png", b"x"),
|
||||||
|
ExportTarget::Remote,
|
||||||
|
"/Shared/Web",
|
||||||
|
&outbox,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Elsewhere, so in nobody's way.
|
||||||
|
place(
|
||||||
|
&encoded("IMG_0001-2.png", b"x"),
|
||||||
|
ExportTarget::Remote,
|
||||||
|
"Shared/Web",
|
||||||
|
&outbox,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let settings = ExportSettings {
|
||||||
|
format: dr_types::ExportFormat::Png,
|
||||||
|
target: ExportTarget::Remote,
|
||||||
|
destination: "/Shared/Web".into(),
|
||||||
|
collision: CollisionPolicy::Increment,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// What the album records of an earlier export.
|
||||||
|
let mut known: HashSet<String> = ["IMG_0001.png".to_string()].into();
|
||||||
|
known.extend(queued_for(&outbox, &settings.destination));
|
||||||
|
|
||||||
|
let name = resolve_batch_name(
|
||||||
|
&settings,
|
||||||
|
&known,
|
||||||
|
&NameContext {
|
||||||
|
source_stem: "IMG_0001",
|
||||||
|
sequence: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&mut HashSet::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(name.as_deref(), Some("IMG_0001-2.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_carries_its_policy_and_an_old_one_has_none() {
|
||||||
|
let dir = tmp();
|
||||||
|
let outbox = dir.join("outbox");
|
||||||
|
place(
|
||||||
|
&encoded("a.jpg", b"x"),
|
||||||
|
ExportTarget::Remote,
|
||||||
|
"E",
|
||||||
|
&outbox,
|
||||||
|
CollisionPolicy::Skip,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let queue = pending(&outbox);
|
||||||
|
assert_eq!(queue[0].collision, Some(CollisionPolicy::Skip));
|
||||||
|
assert!(!queue[0].account);
|
||||||
|
|
||||||
|
std::fs::write(outbox.join("old.jpg"), b"x").unwrap();
|
||||||
|
std::fs::write(outbox.join("old.jpg.dest"), "E\nold.jpg\naccount\n").unwrap();
|
||||||
|
let old = pending(&outbox)
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.name == "old.jpg")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(old.collision, None);
|
||||||
|
assert!(old.account);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_payload_with_no_record_is_ignored() {
|
fn a_payload_with_no_record_is_ignored() {
|
||||||
// The window a kill between the two writes leaves behind. It must not
|
// The window a kill between the two writes leaves behind. It must not
|
||||||
@@ -2020,6 +2414,7 @@ mod tests {
|
|||||||
remote_dir: "Exports/2026".into(),
|
remote_dir: "Exports/2026".into(),
|
||||||
name: "a.jpg".into(),
|
name: "a.jpg".into(),
|
||||||
account: false,
|
account: false,
|
||||||
|
collision: None,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entry.remote_path("Photos").as_str(),
|
entry.remote_path("Photos").as_str(),
|
||||||
@@ -2040,6 +2435,7 @@ mod tests {
|
|||||||
remote_dir: String::new(),
|
remote_dir: String::new(),
|
||||||
name: "a.jpg".into(),
|
name: "a.jpg".into(),
|
||||||
account: false,
|
account: false,
|
||||||
|
collision: None,
|
||||||
};
|
};
|
||||||
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
|
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
|
||||||
}
|
}
|
||||||
@@ -2053,6 +2449,7 @@ mod tests {
|
|||||||
remote_dir: "/Exports/".into(),
|
remote_dir: "/Exports/".into(),
|
||||||
name: "a.jpg".into(),
|
name: "a.jpg".into(),
|
||||||
account: false,
|
account: false,
|
||||||
|
collision: None,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entry.remote_path("/Photos/").as_str(),
|
entry.remote_path("/Photos/").as_str(),
|
||||||
@@ -2077,6 +2474,7 @@ mod tests {
|
|||||||
ExportTarget::Remote,
|
ExportTarget::Remote,
|
||||||
"/Shared/Web",
|
"/Shared/Web",
|
||||||
&dir,
|
&dir,
|
||||||
|
CollisionPolicy::Increment,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -2125,6 +2523,7 @@ mod tests {
|
|||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
conn: None,
|
conn: None,
|
||||||
settings,
|
settings,
|
||||||
|
remote_names: HashSet::new(),
|
||||||
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
|
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
|
||||||
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
|
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
|
||||||
offline: true,
|
offline: true,
|
||||||
@@ -2432,6 +2831,7 @@ mod tests {
|
|||||||
let mut issued = HashSet::new();
|
let mut issued = HashSet::new();
|
||||||
let name = resolve_batch_name(
|
let name = resolve_batch_name(
|
||||||
&settings,
|
&settings,
|
||||||
|
&HashSet::new(),
|
||||||
&NameContext {
|
&NameContext {
|
||||||
source_stem: "IMG_0001",
|
source_stem: "IMG_0001",
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
|||||||
+100
-1
@@ -68,6 +68,13 @@ pub struct ImportController {
|
|||||||
selected: Cell<i32>,
|
selected: Cell<i32>,
|
||||||
/// The source. Typed or chosen; a card the app did not find is still a card.
|
/// The source. Typed or chosen; a card the app did not find is still a card.
|
||||||
card: RefCell<String>,
|
card: RefCell<String>,
|
||||||
|
/// Android only: the user has not granted "all files access", so no card
|
||||||
|
/// can be read and the page asks for it instead of listing volumes.
|
||||||
|
needs_access: Cell<bool>,
|
||||||
|
/// Android only: watches for that grant after the settings page has been
|
||||||
|
/// opened, so the list fills in when the user comes back.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
access_watch: RefCell<Option<slint::Timer>>,
|
||||||
|
|
||||||
/// What the last survey found, `None` before one has run.
|
/// What the last survey found, `None` before one has run.
|
||||||
survey: RefCell<Option<(usize, u64)>>,
|
survey: RefCell<Option<(usize, u64)>>,
|
||||||
@@ -106,6 +113,8 @@ impl ImportController {
|
|||||||
volumes: RefCell::new(Vec::new()),
|
volumes: RefCell::new(Vec::new()),
|
||||||
selected: Cell::new(-1),
|
selected: Cell::new(-1),
|
||||||
card: RefCell::new(String::new()),
|
card: RefCell::new(String::new()),
|
||||||
|
needs_access: Cell::new(false),
|
||||||
|
access_watch: RefCell::new(None),
|
||||||
survey: RefCell::new(None),
|
survey: RefCell::new(None),
|
||||||
surveying: Cell::new(false),
|
surveying: Cell::new(false),
|
||||||
upload_target: RefCell::new(String::new()),
|
upload_target: RefCell::new(String::new()),
|
||||||
@@ -137,7 +146,7 @@ impl ImportController {
|
|||||||
|
|
||||||
/// Look for cards. Cheap, and safe to call whenever the page is shown.
|
/// Look for cards. Cheap, and safe to call whenever the page is shown.
|
||||||
fn refresh_volumes(&self) {
|
fn refresh_volumes(&self) {
|
||||||
let found = dr_plat::volumes();
|
let found = self.find_volumes();
|
||||||
// Keep a typed path: a refresh must not discard what the user entered
|
// Keep a typed path: a refresh must not discard what the user entered
|
||||||
// because the app happened to find three other volumes.
|
// because the app happened to find three other volumes.
|
||||||
if self.card.borrow().is_empty() {
|
if self.card.borrow().is_empty() {
|
||||||
@@ -160,6 +169,34 @@ impl ImportController {
|
|||||||
*self.volumes.borrow_mut() = found;
|
*self.volumes.borrow_mut() = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mount table, on the desktop.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
|
||||||
|
dr_plat::volumes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `StorageManager`, on Android, once the user has allowed the app to read
|
||||||
|
/// a card at all (see [`crate::cards`]).
|
||||||
|
///
|
||||||
|
/// The path cannot be typed here — the field is hidden on Android — so a
|
||||||
|
/// remembered path whose card has been taken out is dropped rather than
|
||||||
|
/// left selected with no way to change it.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
|
||||||
|
let granted = crate::cards::has_access();
|
||||||
|
self.needs_access.set(!granted);
|
||||||
|
let found = if granted {
|
||||||
|
crate::cards::volumes()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let card = self.card.borrow().clone();
|
||||||
|
if !found.iter().any(|v| v.path.display().to_string() == card) {
|
||||||
|
self.card.borrow_mut().clear();
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
/// The transfer options a run is started with.
|
/// The transfer options a run is started with.
|
||||||
fn ingest_options(&self) -> Options {
|
fn ingest_options(&self) -> Options {
|
||||||
let stored = self.options();
|
let stored = self.options();
|
||||||
@@ -239,6 +276,7 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
|
|||||||
!card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)),
|
!card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)),
|
||||||
);
|
);
|
||||||
window.set_import_card_path(card.into());
|
window.set_import_card_path(card.into());
|
||||||
|
window.set_import_needs_access(ctl.needs_access.get());
|
||||||
|
|
||||||
window.set_import_surveying(ctl.surveying.get());
|
window.set_import_surveying(ctl.surveying.get());
|
||||||
window.set_import_survey_summary(
|
window.set_import_survey_summary(
|
||||||
@@ -428,6 +466,20 @@ where
|
|||||||
render(&w, &ctl);
|
render(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let context = context.clone();
|
||||||
|
window.on_import_grant_access(move || {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
watch_for_access(&weak, &ctl, &context);
|
||||||
|
// Nothing to grant elsewhere; the control is shown only when
|
||||||
|
// `needs_access` is set, which only Android sets.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let _ = (&weak, &ctl, &context);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options.
|
/// Options.
|
||||||
@@ -567,6 +619,53 @@ fn wire_running<C, F>(
|
|||||||
|
|
||||||
/// Take a source the user named — typed, or chosen in the dialogue — rather
|
/// Take a source the user named — typed, or chosen in the dialogue — rather
|
||||||
/// than one from the volume list, and count what is on it.
|
/// than one from the volume list, and count what is on it.
|
||||||
|
/// Send the user to the system page that grants "all files access", and
|
||||||
|
/// fill the page in once they have.
|
||||||
|
///
|
||||||
|
/// The grant is a settings switch, not a dialog with an answer, and nothing
|
||||||
|
/// tells the app when it flips. So the page asks again twice a second until
|
||||||
|
/// it has it, or for five minutes — long enough to find the switch, short
|
||||||
|
/// enough that a user who walked away is not polled for ever. Coming back to
|
||||||
|
/// the page later re-checks anyway (`import_open`).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn watch_for_access<C>(weak: &slint::Weak<AppWindow>, ctl: &Rc<ImportController>, context: &Rc<C>)
|
||||||
|
where
|
||||||
|
C: Fn() -> Option<Context> + 'static,
|
||||||
|
{
|
||||||
|
crate::cards::request_access();
|
||||||
|
|
||||||
|
let since = std::time::Instant::now();
|
||||||
|
let (weak, held, context) = (weak.clone(), ctl.clone(), context.clone());
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::Repeated,
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
move || {
|
||||||
|
let granted = crate::cards::has_access();
|
||||||
|
if !granted && since.elapsed() < std::time::Duration::from_secs(300) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(t) = held.access_watch.borrow().as_ref() {
|
||||||
|
t.stop();
|
||||||
|
}
|
||||||
|
// Released outside its own callback, as `saf::pick_tree` does: a
|
||||||
|
// timer dropped from inside the closure it is running is dropping
|
||||||
|
// that closure mid-call.
|
||||||
|
let release = held.clone();
|
||||||
|
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
|
||||||
|
release.access_watch.borrow_mut().take();
|
||||||
|
});
|
||||||
|
if granted {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
held.refresh_volumes();
|
||||||
|
survey(&w, &held, &context);
|
||||||
|
render(&w, &held);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*ctl.access_watch.borrow_mut() = Some(timer);
|
||||||
|
}
|
||||||
|
|
||||||
fn set_card(
|
fn set_card(
|
||||||
w: &AppWindow,
|
w: &AppWindow,
|
||||||
ctl: &Rc<ImportController>,
|
ctl: &Rc<ImportController>,
|
||||||
|
|||||||
@@ -208,12 +208,19 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
// the pipeline, and the photographer is choosing how the scene's range
|
// the pipeline, and the photographer is choosing how the scene's range
|
||||||
// is fitted onto the screen.
|
// is fitted onto the screen.
|
||||||
"op.view_transform" => "Tone Mapping",
|
"op.view_transform" => "Tone Mapping",
|
||||||
|
// The DNG camera profile's tables (D20). "Camera Profile", as Camera
|
||||||
|
// Raw calls the same thing, so a photographer arriving from Lightroom
|
||||||
|
// finds it under the name they know.
|
||||||
|
"op.camera_profile" => "Camera Profile",
|
||||||
|
|
||||||
// Parameters
|
// Parameters
|
||||||
// Named for what it does rather than what it is, since a lone
|
// Named for what it does rather than what it is, since a lone
|
||||||
// parameter is titled by its operation and this one never reaches the
|
// parameter is titled by its operation and this one never reaches the
|
||||||
// panel under its own name — see `rows_filtered`.
|
// panel under its own name — see `rows_filtered`.
|
||||||
"param.lens_profile.apply" => "Apply",
|
"param.lens_profile.apply" => "Apply",
|
||||||
|
"param.camera_profile.apply" => "Use Profile",
|
||||||
|
// The LookTable's strength, as Lightroom's profile "Amount".
|
||||||
|
"param.camera_profile.look" => "Look Amount",
|
||||||
"param.view_transform.contrast" => "Contrast",
|
"param.view_transform.contrast" => "Contrast",
|
||||||
// In stops above middle grey: where the scene reaches display white.
|
// In stops above middle grey: where the scene reaches display white.
|
||||||
"param.view_transform.white" => "White Point",
|
"param.view_transform.white" => "White Point",
|
||||||
|
|||||||
+148
-19
@@ -24,6 +24,7 @@ mod albums_ui;
|
|||||||
#[cfg(all(feature = "automation", unix))]
|
#[cfg(all(feature = "automation", unix))]
|
||||||
mod automation;
|
mod automation;
|
||||||
mod bursts;
|
mod bursts;
|
||||||
|
mod cards;
|
||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod decoder_seam;
|
mod decoder_seam;
|
||||||
@@ -323,6 +324,13 @@ pub(crate) fn open_session(
|
|||||||
// the capture date because of this line; before it, the same photograph
|
// the capture date because of this line; before it, the same photograph
|
||||||
// exported from the grid kept them and exported from develop did not.
|
// exported from the grid kept them and exported from develop did not.
|
||||||
session.set_source_metadata(meta.clone());
|
session.set_source_metadata(meta.clone());
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
// The embedded camera profile, read again from the header, for the info
|
||||||
|
// panel's offer to copy it. Only a DNG carries one; the read is the
|
||||||
|
// header's IFDs, not the photosites.
|
||||||
|
if dr_decode::probe(bytes) != Some(dr_types::Format::Jpeg) {
|
||||||
|
session.set_embedded_profile(dr_decode::dcp::embedded_in(bytes));
|
||||||
|
}
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,6 +719,7 @@ fn batch_request(
|
|||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
conn: library.credentials(),
|
conn: library.credentials(),
|
||||||
settings: stored.export,
|
settings: stored.export,
|
||||||
|
remote_names: Default::default(),
|
||||||
outbox: match library.session() {
|
outbox: match library.session() {
|
||||||
Some(c) => export::outbox_dir(&c.account),
|
Some(c) => export::outbox_dir(&c.account),
|
||||||
// No account, so no outbox — a device export still works, and a
|
// No account, so no outbox — a device export still works, and a
|
||||||
@@ -792,29 +801,36 @@ pub(crate) fn refresh_export_label(window: &AppWindow) {
|
|||||||
/// dragged, because each move event destroys the thing that would deliver
|
/// dragged, because each move event destroys the thing that would deliver
|
||||||
/// the next one.
|
/// the next one.
|
||||||
/// TRACES: FR-CAT-8
|
/// TRACES: FR-CAT-8
|
||||||
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
|
/// Apply a fetched sidecar to the open session, then draw its first frame.
|
||||||
///
|
///
|
||||||
/// The sidecar fetch is started beside the image fetch and is three orders of
|
/// The sidecar fetch is started beside the image fetch and is three orders of
|
||||||
/// magnitude smaller, so it has almost always landed by the time there is a
|
/// magnitude smaller, so it has usually landed by the time there is a session
|
||||||
/// session to apply it to — and this takes it straight from the channel. The
|
/// to apply it to — and this takes it straight from the channel. An original
|
||||||
/// timer covers the case where it has not, which is why this is not simply a
|
/// read from the cache can still beat it, and drawing then showed the
|
||||||
/// blocking receive: a slow or stalled sidecar request must not freeze the
|
/// photograph at its defaults and changed it a moment later. So the first
|
||||||
/// window with the photograph already decoded and on screen.
|
/// frame waits up to `SIDECAR_GRACE` for the edit, with the grid's thumbnail
|
||||||
|
/// still standing in; past that it is drawn at its defaults, and a later
|
||||||
|
/// arrival redraws. Not a blocking receive: a stalled request must not freeze
|
||||||
|
/// the window.
|
||||||
///
|
///
|
||||||
/// A late arrival redraws, so the image is correct either way; the only
|
/// `still_current` is false once the view has moved to another photograph,
|
||||||
/// difference is whether it was ever briefly shown at its defaults.
|
/// whose session this sidecar must not be applied to.
|
||||||
fn apply_when_ready(
|
fn apply_when_ready(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
||||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
rows: &Rc<slint::VecModel<ParamRow>>,
|
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||||||
|
still_current: impl Fn() -> bool + 'static,
|
||||||
) {
|
) {
|
||||||
// Already here — the overwhelmingly common case.
|
const SIDECAR_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
|
||||||
|
|
||||||
|
// Already here — the common case.
|
||||||
if let Ok(got) = rx.try_recv() {
|
if let Ok(got) = rx.try_recv() {
|
||||||
if let Some(sidecar) = got {
|
if let Some(sidecar) = got {
|
||||||
presets::apply_stored_edit(window, &sidecar, session, rows);
|
presets::apply_stored_edit(window, &sidecar, session, rows);
|
||||||
}
|
}
|
||||||
|
redraw(window);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,17 +838,37 @@ fn apply_when_ready(
|
|||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let rows = rows.clone();
|
let rows = rows.clone();
|
||||||
let redraw = redraw.clone();
|
let redraw = redraw.clone();
|
||||||
|
let deadline = std::time::Instant::now() + SIDECAR_GRACE;
|
||||||
|
let drawn = Cell::new(false);
|
||||||
let timer = Rc::new(slint::Timer::default());
|
let timer = Rc::new(slint::Timer::default());
|
||||||
let held = timer.clone();
|
let held = timer.clone();
|
||||||
timer.start(
|
timer.start(
|
||||||
slint::TimerMode::Repeated,
|
slint::TimerMode::Repeated,
|
||||||
std::time::Duration::from_millis(50),
|
std::time::Duration::from_millis(50),
|
||||||
move || {
|
move || {
|
||||||
let Ok(got) = rx.try_recv() else { return };
|
let Some(w) = weak.upgrade() else {
|
||||||
|
held.stop();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !still_current() {
|
||||||
|
held.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(got) = rx.try_recv() else {
|
||||||
|
// Waited long enough: draw at the defaults and keep
|
||||||
|
// listening, so a late edit still lands.
|
||||||
|
if !drawn.get() && std::time::Instant::now() >= deadline {
|
||||||
|
drawn.set(true);
|
||||||
|
redraw(&w);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
held.stop();
|
held.stop();
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let applied = match got {
|
||||||
let Some(sidecar) = got else { return };
|
Some(sidecar) => presets::apply_stored_edit(&w, &sidecar, &session, &rows),
|
||||||
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
|
None => false,
|
||||||
|
};
|
||||||
|
if applied || !drawn.get() {
|
||||||
redraw(&w);
|
redraw(&w);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1165,6 +1201,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis());
|
log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis());
|
||||||
|
|
||||||
let window = init_window(&gpu)?;
|
let window = init_window(&gpu)?;
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
// The installed camera profiles, before anything decodes (D20). A
|
||||||
|
// directory of a few `.dcp` files, each a couple of hundred kilobytes,
|
||||||
|
// so read here rather than deferred: a photograph opened before a
|
||||||
|
// background load finished would render without its profile once and
|
||||||
|
// with it the next time.
|
||||||
|
let profiles_began = std::time::Instant::now();
|
||||||
|
dr_decode::dcp::set_profiles_directory(library::data_root().join("profiles"));
|
||||||
|
log::info!(
|
||||||
|
"camera profiles read in {} ms",
|
||||||
|
profiles_began.elapsed().as_millis()
|
||||||
|
);
|
||||||
wire_inference_status(&window);
|
wire_inference_status(&window);
|
||||||
wire_diagnostics(&window, &gpu);
|
wire_diagnostics(&window, &gpu);
|
||||||
|
|
||||||
@@ -1217,6 +1266,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let index = Rc::new(RefCell::new(0usize));
|
let index = Rc::new(RefCell::new(0usize));
|
||||||
// The current develop session, if the file yielded sensor data.
|
// The current develop session, if the file yielded sensor data.
|
||||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||||
|
wire_profile_offer(&window, &session);
|
||||||
|
|
||||||
// TRACES: FR-PLAT-AND-5
|
// TRACES: FR-PLAT-AND-5
|
||||||
// The GPU tier — the first thing given back under memory pressure, and on
|
// The GPU tier — the first thing given back under memory pressure, and on
|
||||||
@@ -1450,6 +1500,43 @@ fn init_window(gpu: &Option<dr_gpu::GpuContext>) -> Result<AppWindow> {
|
|||||||
/// What the models run on. Re-read every two seconds because the answer
|
/// What the models run on. Re-read every two seconds because the answer
|
||||||
/// changes twice after launch — when the probe reports and as each
|
/// changes twice after launch — when the probe reports and as each
|
||||||
/// engine lands — and the page is open for longer than either takes.
|
/// engine lands — and the page is open for longer than either takes.
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The info panel's camera profile line and its offer, for the session just
|
||||||
|
/// opened — or cleared, for none.
|
||||||
|
fn show_camera_profile(window: &AppWindow, session: Option<&DevelopSession>) {
|
||||||
|
window.global::<Capture>().set_profile(
|
||||||
|
session
|
||||||
|
.map(DevelopSession::profile_summary)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.global::<ProfileOffer>().set_offer(
|
||||||
|
session
|
||||||
|
.and_then(DevelopSession::profile_offer)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Saving the open DNG's embedded profile for every photograph from its
|
||||||
|
/// body (D20). The photograph on screen already renders through it; the
|
||||||
|
/// others pick it up when they are next decoded.
|
||||||
|
fn wire_profile_offer(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
window.global::<ProfileOffer>().on_adopt(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let mut guard = session.borrow_mut();
|
||||||
|
let Some(s) = guard.as_mut() else { return };
|
||||||
|
match s.adopt_profile() {
|
||||||
|
Ok(path) => log::info!("camera profile saved to {}", path.display()),
|
||||||
|
Err(e) => log::warn!("camera profile not saved: {e}"),
|
||||||
|
}
|
||||||
|
show_camera_profile(&w, Some(s));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn wire_inference_status(window: &AppWindow) {
|
fn wire_inference_status(window: &AppWindow) {
|
||||||
let set = |w: &AppWindow| {
|
let set = |w: &AppWindow| {
|
||||||
let (line, detail) = inference::about_lines();
|
let (line, detail) = inference::about_lines();
|
||||||
@@ -2151,6 +2238,15 @@ fn build_render_now(
|
|||||||
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
|
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-4
|
||||||
|
// A zoomed view keeps the viewport's shape through a resize or a
|
||||||
|
// crop that changes the frame's. First, because the overlays
|
||||||
|
// below are placed against the view this may re-cut.
|
||||||
|
{
|
||||||
|
let (vw, vh) = *viewport.borrow();
|
||||||
|
s.shape_view_to(vw, vh);
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-3
|
// TRACES: FR-DEV-3
|
||||||
// Which part of the region overlay the view is showing. Here
|
// Which part of the region overlay the view is showing. Here
|
||||||
// rather than in the panel's own sync because a pan or a zoom
|
// rather than in the panel's own sync because a pan or a zoom
|
||||||
@@ -2242,6 +2338,13 @@ fn build_render_now(
|
|||||||
window.set_canvas_draft(draft);
|
window.set_canvas_draft(draft);
|
||||||
window.global::<Levels>().set_provisional(draft);
|
window.global::<Levels>().set_provisional(draft);
|
||||||
window.set_load_error("".into());
|
window.set_load_error("".into());
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// The library path keeps the grid's thumbnail up until
|
||||||
|
// here, the first frame of the new photograph, because
|
||||||
|
// the canvas still holds the last photograph's texture
|
||||||
|
// until this line replaces it.
|
||||||
|
window.set_load_pending(false);
|
||||||
|
window.set_has_load_preview(false);
|
||||||
// The readout and the "Fit" button follow the session
|
// The readout and the "Fit" button follow the session
|
||||||
// rather than the gesture, so a clamped zoom shows the
|
// rather than the gesture, so a clamped zoom shows the
|
||||||
// value that was actually applied.
|
// value that was actually applied.
|
||||||
@@ -2502,6 +2605,7 @@ fn build_show(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
show_camera_profile(window, l.session.as_ref());
|
||||||
|
|
||||||
// The panel is built from what the pipeline reports, so
|
// The panel is built from what the pipeline reports, so
|
||||||
// this code names no operation (FR-DEV-3a).
|
// this code names no operation (FR-DEV-3a).
|
||||||
@@ -2559,6 +2663,7 @@ fn build_show(
|
|||||||
capture.set_exposure("".into());
|
capture.set_exposure("".into());
|
||||||
capture.set_dimensions("".into());
|
capture.set_dimensions("".into());
|
||||||
capture.set_lens("".into());
|
capture.set_lens("".into());
|
||||||
|
show_camera_profile(window, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2646,6 +2751,8 @@ fn wire_remote_open(
|
|||||||
capture.set_camera("".into());
|
capture.set_camera("".into());
|
||||||
capture.set_exposure("".into());
|
capture.set_exposure("".into());
|
||||||
capture.set_dimensions("".into());
|
capture.set_dimensions("".into());
|
||||||
|
capture.set_lens("".into());
|
||||||
|
show_camera_profile(&w, None);
|
||||||
// The grid is one image at a time, so next/previous have nothing
|
// The grid is one image at a time, so next/previous have nothing
|
||||||
// to walk. Shown as 1 of 1 rather than left reading 0.
|
// to walk. Shown as 1 of 1 rather than left reading 0.
|
||||||
w.set_index(0);
|
w.set_index(0);
|
||||||
@@ -2777,13 +2884,18 @@ fn wire_remote_open(
|
|||||||
log::debug!("{name}: landed after the view moved on");
|
log::debug!("{name}: landed after the view moved on");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
w.set_load_pending(false);
|
// The bar goes, and the thumbnail goes back to full
|
||||||
|
// strength; the thumbnail itself stays until the first
|
||||||
|
// frame of this photograph replaces it in `render_now`.
|
||||||
|
// Dropping it here showed the last photograph's texture,
|
||||||
|
// still in the canvas, for the length of the decode.
|
||||||
w.set_load_waiting("".into());
|
w.set_load_waiting("".into());
|
||||||
w.set_has_load_preview(false);
|
|
||||||
|
|
||||||
let bytes = match got {
|
let bytes = match got {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
w.set_load_pending(false);
|
||||||
|
w.set_has_load_preview(false);
|
||||||
job.fail(e.message.clone());
|
job.fail(e.message.clone());
|
||||||
log::warn!("{name}: {e}");
|
log::warn!("{name}: {e}");
|
||||||
// Offline needs its own words. "network error:
|
// Offline needs its own words. "network error:
|
||||||
@@ -2818,6 +2930,14 @@ fn wire_remote_open(
|
|||||||
capture.set_camera(describe_camera(&l.meta).into());
|
capture.set_camera(describe_camera(&l.meta).into());
|
||||||
capture.set_exposure(describe_exposure(&l.meta).into());
|
capture.set_exposure(describe_exposure(&l.meta).into());
|
||||||
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||||||
|
capture.set_lens(
|
||||||
|
l.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.lens_summary())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
show_camera_profile(&w, l.session.as_ref());
|
||||||
match l.session {
|
match l.session {
|
||||||
Some(mut s) => {
|
Some(mut s) => {
|
||||||
w.global::<Develop>().set_enabled(true);
|
w.global::<Develop>().set_enabled(true);
|
||||||
@@ -2852,16 +2972,19 @@ fn wire_remote_open(
|
|||||||
// TRACES: FR-CAT-8
|
// TRACES: FR-CAT-8
|
||||||
// The stored edit, if it has landed. It
|
// The stored edit, if it has landed. It
|
||||||
// was started before the download of a
|
// was started before the download of a
|
||||||
// file thousands of times its size, so in
|
// file thousands of times its size, but
|
||||||
// practice it has; `apply_when_ready`
|
// an original read from the cache can
|
||||||
// covers the case where it has not rather
|
// beat it; `apply_when_ready` holds the
|
||||||
|
// first frame back a moment for it rather
|
||||||
// than blocking the UI thread on a socket.
|
// than blocking the UI thread on a socket.
|
||||||
|
let still = current.clone();
|
||||||
apply_when_ready(
|
apply_when_ready(
|
||||||
&w,
|
&w,
|
||||||
sidecar_rx.clone(),
|
sidecar_rx.clone(),
|
||||||
&session,
|
&session,
|
||||||
&rows,
|
&rows,
|
||||||
&redraw,
|
&redraw,
|
||||||
|
move || still.get() == mine,
|
||||||
);
|
);
|
||||||
// TRACES: FR-UI-4
|
// TRACES: FR-UI-4
|
||||||
// Under the same magnifier as the last
|
// Under the same magnifier as the last
|
||||||
@@ -2874,12 +2997,16 @@ fn wire_remote_open(
|
|||||||
// moves the point rather than losing it.
|
// moves the point rather than losing it.
|
||||||
resume_inspection(&session, &viewport, &inspection);
|
resume_inspection(&session, &viewport, &inspection);
|
||||||
sync_rows(&w, &rows, &session);
|
sync_rows(&w, &rows, &session);
|
||||||
redraw(&w);
|
// No redraw here: `apply_when_ready` makes
|
||||||
|
// the first one, once the edit is applied
|
||||||
|
// or has been waited for long enough.
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
*session.borrow_mut() = None;
|
*session.borrow_mut() = None;
|
||||||
rows.set_vec(Vec::<ParamRow>::new());
|
rows.set_vec(Vec::<ParamRow>::new());
|
||||||
w.global::<Develop>().set_enabled(false);
|
w.global::<Develop>().set_enabled(false);
|
||||||
|
w.set_load_pending(false);
|
||||||
|
w.set_has_load_preview(false);
|
||||||
if let Some(image) = l.fallback {
|
if let Some(image) = l.fallback {
|
||||||
w.set_canvas(image);
|
w.set_canvas(image);
|
||||||
}
|
}
|
||||||
@@ -2891,6 +3018,8 @@ fn wire_remote_open(
|
|||||||
log::warn!("{name}: {e}");
|
log::warn!("{name}: {e}");
|
||||||
*session.borrow_mut() = None;
|
*session.borrow_mut() = None;
|
||||||
w.global::<Develop>().set_enabled(false);
|
w.global::<Develop>().set_enabled(false);
|
||||||
|
w.set_load_pending(false);
|
||||||
|
w.set_has_load_preview(false);
|
||||||
w.set_load_error(e.into());
|
w.set_load_error(e.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ pub fn place_path(account: &Account) -> PathBuf {
|
|||||||
/// desktop declares nothing and takes the platform's data directory from
|
/// desktop declares nothing and takes the platform's data directory from
|
||||||
/// `dr_plat::dirs` — XDG on Linux, `%LOCALAPPDATA%` on Windows — which keeps
|
/// `dr_plat::dirs` — XDG on Linux, `%LOCALAPPDATA%` on Windows — which keeps
|
||||||
/// the established location on Linux rather than moving anyone's catalog.
|
/// the established location on Linux rather than moving anyone's catalog.
|
||||||
pub(super) fn data_root() -> PathBuf {
|
pub(crate) fn data_root() -> PathBuf {
|
||||||
match dr_sync::account::declared_data_dir() {
|
match dr_sync::account::declared_data_dir() {
|
||||||
Some(declared) => declared.join("darkroom"),
|
Some(declared) => declared.join("darkroom"),
|
||||||
None => dr_plat::base_dir(dr_plat::Base::Data),
|
None => dr_plat::base_dir(dr_plat::Base::Data),
|
||||||
|
|||||||
@@ -522,11 +522,16 @@ pub(super) fn flush_sweep(catalog: &Catalog, found: &mut Vec<MetadataFound>) {
|
|||||||
.filter(|m| m.captured_at.is_none())
|
.filter(|m| m.captured_at.is_none())
|
||||||
.map(|m| m.image_id)
|
.map(|m| m.image_id)
|
||||||
.collect();
|
.collect();
|
||||||
for id in ids {
|
for &id in &ids {
|
||||||
let _ = catalog
|
let _ = catalog
|
||||||
.connection()
|
.connection()
|
||||||
.execute("UPDATE images SET metadata_state = 2 WHERE id = ?1", [id]);
|
.execute("UPDATE images SET metadata_state = 2 WHERE id = ?1", [id]);
|
||||||
}
|
}
|
||||||
|
// A header with no date is not a photograph with no date: WhatsApp and
|
||||||
|
// most re-exports strip EXIF and keep the date in the name.
|
||||||
|
if let Err(e) = dr_catalog::name_dates::fill(catalog.connection(), Some(&ids)) {
|
||||||
|
log::warn!("sweep: dating from names: {e}");
|
||||||
|
}
|
||||||
found.clear();
|
found.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-11
@@ -42,7 +42,7 @@ use dr_gpu::{
|
|||||||
};
|
};
|
||||||
use dr_pano::bundle::Cameras;
|
use dr_pano::bundle::Cameras;
|
||||||
use dr_pano::projection::{self, Projection};
|
use dr_pano::projection::{self, Projection};
|
||||||
use dr_pano::{Alignment, Gray};
|
use dr_pano::{Alignment, Gray, SeamMap};
|
||||||
use dr_pipeline::EditGraph;
|
use dr_pipeline::EditGraph;
|
||||||
|
|
||||||
pub use crate::export::Cancel;
|
pub use crate::export::Cancel;
|
||||||
@@ -103,8 +103,14 @@ pub struct MergeRequest {
|
|||||||
pub destination: MergeDestination,
|
pub destination: MergeDestination,
|
||||||
/// `None` for the projection the field of view suggests.
|
/// `None` for the projection the field of view suggests.
|
||||||
pub projection: Option<Projection>,
|
pub projection: Option<Projection>,
|
||||||
/// Pixels over which a frame's weight ramps up from its edge.
|
/// Pixels over which a frame's weight ramps up from its edge, where no
|
||||||
|
/// seam says which frame a pixel is taken from.
|
||||||
pub feather_px: f32,
|
pub feather_px: f32,
|
||||||
|
/// Whether overlaps are cut along seams (`dr_pano::seam`) or averaged
|
||||||
|
/// across `feather_px` everywhere.
|
||||||
|
pub seams: bool,
|
||||||
|
/// The width, in output pixels, of the blend across a seam.
|
||||||
|
pub seam_blend_px: f32,
|
||||||
/// GPU work unit; also the DNG strip height.
|
/// GPU work unit; also the DNG strip height.
|
||||||
pub chunk: (u32, u32),
|
pub chunk: (u32, u32),
|
||||||
/// The border filler's weights, if the device has them (FR-MRG-4).
|
/// The border filler's weights, if the device has them (FR-MRG-4).
|
||||||
@@ -122,6 +128,8 @@ impl MergeRequest {
|
|||||||
destination,
|
destination,
|
||||||
projection: None,
|
projection: None,
|
||||||
feather_px: 200.0,
|
feather_px: 200.0,
|
||||||
|
seams: true,
|
||||||
|
seam_blend_px: 64.0,
|
||||||
chunk: (2048, 512),
|
chunk: (2048, 512),
|
||||||
inpaint_model: None,
|
inpaint_model: None,
|
||||||
decoder: dr_decode::default(),
|
decoder: dr_decode::default(),
|
||||||
@@ -478,11 +486,20 @@ fn run_inner(
|
|||||||
let preview = match s.alignment.is_complete() {
|
let preview = match s.alignment.is_complete() {
|
||||||
false => None,
|
false => None,
|
||||||
true => {
|
true => {
|
||||||
|
let map = request
|
||||||
|
.seams
|
||||||
|
.then(|| seams(&proxies, &s.alignment, &s.gains, projection))
|
||||||
|
.flatten();
|
||||||
|
let blend = map.as_deref().map(|m| {
|
||||||
|
let full = m.scale * s.focal_full / s.alignment.focal;
|
||||||
|
(m, m.blend_radius(full, f64::from(request.seam_blend_px)))
|
||||||
|
});
|
||||||
let (w, h, mut rgb, mut known) = preview_planes(
|
let (w, h, mut rgb, mut known) = preview_planes(
|
||||||
&colour,
|
&colour,
|
||||||
&proxies,
|
&proxies,
|
||||||
&s.alignment,
|
&s.alignment,
|
||||||
&s.gains,
|
&s.gains,
|
||||||
|
blend,
|
||||||
projection,
|
projection,
|
||||||
first_raw,
|
first_raw,
|
||||||
1600,
|
1600,
|
||||||
@@ -605,6 +622,13 @@ fn run_inner(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The seams, on the surface chosen, over the frames kept.
|
||||||
|
let seam_map = request.seams.then(|| {
|
||||||
|
let kept: Vec<&Gray> = solved.keep.iter().map(|&k| &proxies[k]).collect();
|
||||||
|
seams(&kept, &solved.alignment, &solved.gains, projection)
|
||||||
|
});
|
||||||
|
let seam_map = seam_map.flatten();
|
||||||
|
|
||||||
// From here on only the kept frames exist, indexed as the alignment
|
// From here on only the kept frames exist, indexed as the alignment
|
||||||
// indexes them.
|
// indexes them.
|
||||||
let Solved {
|
let Solved {
|
||||||
@@ -704,6 +728,8 @@ fn run_inner(
|
|||||||
scale: focal_full,
|
scale: focal_full,
|
||||||
bounds,
|
bounds,
|
||||||
feather: request.feather_px,
|
feather: request.feather_px,
|
||||||
|
seams: seam_map.clone(),
|
||||||
|
seam_blend: request.seam_blend_px,
|
||||||
chunk: request.chunk,
|
chunk: request.chunk,
|
||||||
sample_scale: white_level as f32,
|
sample_scale: white_level as f32,
|
||||||
balance,
|
balance,
|
||||||
@@ -746,6 +772,8 @@ fn run_inner(
|
|||||||
max_v: bounds.max_v / q,
|
max_v: bounds.max_v / q,
|
||||||
},
|
},
|
||||||
feather: request.feather_px,
|
feather: request.feather_px,
|
||||||
|
seams: seam_map.clone(),
|
||||||
|
seam_blend: request.seam_blend_px / q as f32,
|
||||||
chunk: request.chunk,
|
chunk: request.chunk,
|
||||||
sample_scale: white_level as f32,
|
sample_scale: white_level as f32,
|
||||||
balance,
|
balance,
|
||||||
@@ -1227,21 +1255,51 @@ fn solve(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which frame each part of the composite comes from (`dr_pano::seam`), on
|
||||||
|
/// `projection` at the proxies' scale. `None` when the alignment is not
|
||||||
|
/// complete — the merge then falls back to the feathered average.
|
||||||
|
fn seams(
|
||||||
|
proxies: &[&Gray],
|
||||||
|
alignment: &Alignment,
|
||||||
|
gains: &[f32],
|
||||||
|
projection: Projection,
|
||||||
|
) -> Option<Arc<SeamMap>> {
|
||||||
|
if !alignment.is_complete() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let t = Instant::now();
|
||||||
|
let map = dr_pano::seam::find(
|
||||||
|
proxies,
|
||||||
|
&alignment.cameras(),
|
||||||
|
gains,
|
||||||
|
projection,
|
||||||
|
&dr_pano::SeamOptions::default(),
|
||||||
|
);
|
||||||
|
log::info!(
|
||||||
|
"merge: seams over {} frames in {:?}",
|
||||||
|
proxies.len(),
|
||||||
|
t.elapsed()
|
||||||
|
);
|
||||||
|
map.map(Arc::new)
|
||||||
|
}
|
||||||
|
|
||||||
/// The aligned set on its surface, in colour, for the page.
|
/// The aligned set on its surface, in colour, for the page.
|
||||||
///
|
///
|
||||||
/// A quick look, not the pipeline: the first frame's white balance and
|
/// A quick look, not the pipeline: the first frame's white balance and
|
||||||
/// matrix, a gamma, and the frames averaged where they overlap with their
|
/// matrix, a gamma, and the frames joined along the same seams the merge
|
||||||
/// gains applied. Ghosting here is the alignment's error and banding is
|
/// will use, with their gains applied. Ghosting here is the alignment's
|
||||||
/// the gains', which is exactly what the photographer is being asked to
|
/// error and banding is the gains', which is exactly what the photographer
|
||||||
/// look at. Fitted to 1600 px across.
|
/// is being asked to look at. Fitted to 1600 px across.
|
||||||
/// The aligned set on its surface, in colour, as planes: `(w, h, rgb 0..1,
|
/// The aligned set on its surface, in colour, as planes: `(w, h, rgb 0..1,
|
||||||
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
||||||
/// a gamma — which is also what the border filler was trained on.
|
/// a gamma — which is also what the border filler was trained on.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn preview_planes(
|
fn preview_planes(
|
||||||
colour: &[&[f32]],
|
colour: &[&[f32]],
|
||||||
proxies: &[&Gray],
|
proxies: &[&Gray],
|
||||||
alignment: &Alignment,
|
alignment: &Alignment,
|
||||||
gains: &[f32],
|
gains: &[f32],
|
||||||
|
seams: Option<(&SeamMap, f64)>,
|
||||||
projection: Projection,
|
projection: Projection,
|
||||||
first: &RawImage,
|
first: &RawImage,
|
||||||
max_width: usize,
|
max_width: usize,
|
||||||
@@ -1264,7 +1322,7 @@ fn preview_planes(
|
|||||||
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
||||||
let d = projection.to_direction(scale, u, v);
|
let d = projection.to_direction(scale, u, v);
|
||||||
let mut sum = [0.0f32; 3];
|
let mut sum = [0.0f32; 3];
|
||||||
let mut n = 0u32;
|
let mut n = 0.0f32;
|
||||||
for (k, g) in proxies.iter().enumerate() {
|
for (k, g) in proxies.iter().enumerate() {
|
||||||
let Some((x, y)) = cameras.project(k, d) else {
|
let Some((x, y)) = cameras.project(k, d) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -1276,16 +1334,21 @@ fn preview_planes(
|
|||||||
let i = (y as usize * g.width + x as usize) * 3;
|
let i = (y as usize * g.width + x as usize) * 3;
|
||||||
let cam =
|
let cam =
|
||||||
grey_if_blown([colour[k][i], colour[k][i + 1], colour[k][i + 2]], look.wb);
|
grey_if_blown([colour[k][i], colour[k][i + 1], colour[k][i + 2]], look.wb);
|
||||||
|
// The seam's share, as the merge weighs it, with the same
|
||||||
|
// floor under it; an even average where there is no map.
|
||||||
|
let w = seams
|
||||||
|
.and_then(|(m, radius)| m.share(k, u, v, scale, radius))
|
||||||
|
.map_or(1.0, |s| s + 1e-4);
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
sum[c] += cam[c] * gains[k];
|
sum[c] += cam[c] * gains[k] * w;
|
||||||
}
|
}
|
||||||
n += 1;
|
n += w;
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if n <= 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let o = oy * out_w + ox;
|
let o = oy * out_w + ox;
|
||||||
let d = look.to_display([sum[0] / n as f32, sum[1] / n as f32, sum[2] / n as f32]);
|
let d = look.to_display([sum[0] / n, sum[1] / n, sum[2] / n]);
|
||||||
rgb[o * 3..o * 3 + 3].copy_from_slice(&d);
|
rgb[o * 3..o * 3 + 3].copy_from_slice(&d);
|
||||||
known[o] = true;
|
known[o] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1823,7 +1823,7 @@ mod tests {
|
|||||||
let rows = presets.rows();
|
let rows = presets.rows();
|
||||||
assert!(rows.iter().all(|r| r.folder && r.depth == 0), "{rows:?}");
|
assert!(rows.iter().all(|r| r.folder && r.depth == 0), "{rows:?}");
|
||||||
let labels: Vec<_> = rows.iter().map(|r| r.label.to_string()).collect();
|
let labels: Vec<_> = rows.iter().map(|r| r.label.to_string()).collect();
|
||||||
assert_eq!(labels, ["Essentials", "Skies", "Film"]);
|
assert_eq!(labels, ["Essentials", "Skies", "Vivid", "Film"]);
|
||||||
|
|
||||||
presets.toggle("shipped/Film");
|
presets.toggle("shipped/Film");
|
||||||
let film: Vec<_> = presets
|
let film: Vec<_> = presets
|
||||||
@@ -1835,7 +1835,7 @@ mod tests {
|
|||||||
assert_eq!(film, ["Colour", "Cinema", "Black and white"]);
|
assert_eq!(film, ["Colour", "Cinema", "Black and white"]);
|
||||||
|
|
||||||
presets.toggle("shipped/Film");
|
presets.toggle("shipped/Film");
|
||||||
assert_eq!(presets.rows().len(), 3);
|
assert_eq!(presets.rows().len(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -740,6 +740,9 @@ fn metadata(
|
|||||||
[target.image.0 as i64],
|
[target.image.0 as i64],
|
||||||
)
|
)
|
||||||
.map_err(|e| Failure::Other(e.to_string()))?;
|
.map_err(|e| Failure::Other(e.to_string()))?;
|
||||||
|
// The name, when the header had no date — as the sweep does.
|
||||||
|
dr_catalog::name_dates::fill(catalog.connection(), Some(&[target.image.0 as i64]))
|
||||||
|
.map_err(|e| Failure::Other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
Ok(1)
|
Ok(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -26,7 +26,7 @@ use jni::strings::JNIStr;
|
|||||||
/// Run `body` with the application context ndk_context holds, on whatever
|
/// Run `body` with the application context ndk_context holds, on whatever
|
||||||
/// thread this is. It is a `Context`, not the activity — see
|
/// thread this is. It is a `Context`, not the activity — see
|
||||||
/// `FolderPicker.start` for what that changes.
|
/// `FolderPicker.start` for what that changes.
|
||||||
fn call<T>(
|
pub(crate) fn call<T>(
|
||||||
what: &str,
|
what: &str,
|
||||||
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
||||||
) -> Result<T, String> {
|
) -> Result<T, String> {
|
||||||
@@ -62,7 +62,7 @@ fn call<T>(
|
|||||||
/// or the application context behind it) the boot loader defined, and the
|
/// or the application context behind it) the boot loader defined, and the
|
||||||
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
|
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
|
||||||
/// which is what the first build on the tablet did.
|
/// which is what the first build on the tablet did.
|
||||||
fn class<'local>(
|
pub(crate) fn class<'local>(
|
||||||
env: &mut jni::Env<'local>,
|
env: &mut jni::Env<'local>,
|
||||||
activity: &JObject,
|
activity: &JObject,
|
||||||
name: &JNIStr,
|
name: &JNIStr,
|
||||||
@@ -80,7 +80,7 @@ fn class<'local>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A Java string result, or `None` for null.
|
/// A Java string result, or `None` for null.
|
||||||
fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
pub(crate) fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
||||||
if value.is_null() {
|
if value.is_null() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// TRACES: FR-UI-5
|
||||||
|
//! Back closes the presets menu, and the next Back returns to the grid.
|
||||||
|
//!
|
||||||
|
//! The menu at the foot of the tool rail is a `PopupWindow`, and showing a
|
||||||
|
//! popup takes focus off the develop view until it closes. The menu had
|
||||||
|
//! nothing focusable of its own, so a Back pressed while it was up had no
|
||||||
|
//! focus item to bubble from, went unanswered, and on Android an unanswered
|
||||||
|
//! Back is the platform's: it closed the application from develop. Dismissing
|
||||||
|
//! a menu with the back gesture is the ordinary thing to do, which is why this
|
||||||
|
//! was seen often.
|
||||||
|
//!
|
||||||
|
//! Runs on Slint's testing backend, as `film_list_reaches_every_stock` does.
|
||||||
|
#![cfg(debug_assertions)]
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use dr_ui::{AppWindow, Develop};
|
||||||
|
use i_slint_backend_testing::{init_no_event_loop, ElementHandle};
|
||||||
|
use slint::platform::{Key, PointerEventButton, WindowEvent};
|
||||||
|
use slint::{ComponentHandle, SharedString};
|
||||||
|
|
||||||
|
const MANAGE: &str = "Save or manage…";
|
||||||
|
|
||||||
|
fn key(app: &AppWindow, k: Key) {
|
||||||
|
let text: SharedString = k.into();
|
||||||
|
app.window()
|
||||||
|
.dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
|
||||||
|
app.window()
|
||||||
|
.dispatch_event(WindowEvent::KeyReleased { text });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu_open(app: &AppWindow) -> bool {
|
||||||
|
ElementHandle::find_by_accessible_label(app, MANAGE)
|
||||||
|
.next()
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn back_closes_the_presets_menu_then_leaves_develop() {
|
||||||
|
init_no_event_loop();
|
||||||
|
let app = AppWindow::new().expect("the window builds on the testing backend");
|
||||||
|
app.window().set_size(slint::LogicalSize::new(1600., 1200.));
|
||||||
|
app.global::<Develop>().set_enabled(true);
|
||||||
|
// The rail offers presets only with a photograph to apply them to.
|
||||||
|
app.set_total(3);
|
||||||
|
let asked = Rc::new(Cell::new(0));
|
||||||
|
app.on_back_requested({
|
||||||
|
let asked = asked.clone();
|
||||||
|
move || {
|
||||||
|
asked.set(asked.get() + 1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.show().unwrap();
|
||||||
|
|
||||||
|
ElementHandle::find_by_accessible_label(&app, "Presets")
|
||||||
|
.next()
|
||||||
|
.expect("the tool rail's presets entry is on screen")
|
||||||
|
.mock_single_click(PointerEventButton::Left);
|
||||||
|
assert!(menu_open(&app), "clicking Presets opens the menu");
|
||||||
|
|
||||||
|
key(&app, Key::Back);
|
||||||
|
assert!(!menu_open(&app), "Back closes the menu");
|
||||||
|
assert_eq!(asked.get(), 0, "closing the menu is the whole step");
|
||||||
|
|
||||||
|
key(&app, Key::Back);
|
||||||
|
assert_eq!(asked.get(), 1, "the next Back reaches the shell");
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ fn an_export_is_the_same_frame_whether_or_not_the_canvas_is_zoomed() {
|
|||||||
let before = top_row_span(&unzoomed.rgba, unzoomed.width);
|
let before = top_row_span(&unzoomed.rgba, unzoomed.width);
|
||||||
|
|
||||||
// What a scroll wheel over the middle of the canvas does.
|
// What a scroll wheel over the middle of the canvas does.
|
||||||
session.zoom_about(4.0, 0.5, 0.5);
|
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
|
||||||
assert!(session.is_zoomed(), "the session did not take the zoom");
|
assert!(session.is_zoomed(), "the session did not take the zoom");
|
||||||
|
|
||||||
let zoomed = session
|
let zoomed = session
|
||||||
@@ -108,7 +108,7 @@ fn a_thumbnail_is_the_same_frame_whether_or_not_the_canvas_is_zoomed() {
|
|||||||
let (w, _, unzoomed) = session.render_thumbnail(32).expect("thumbnail");
|
let (w, _, unzoomed) = session.render_thumbnail(32).expect("thumbnail");
|
||||||
let before = top_row_span(&unzoomed, w);
|
let before = top_row_span(&unzoomed, w);
|
||||||
|
|
||||||
session.zoom_about(4.0, 0.5, 0.5);
|
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
|
||||||
let (_, _, zoomed) = session.render_thumbnail(32).expect("thumbnail");
|
let (_, _, zoomed) = session.render_thumbnail(32).expect("thumbnail");
|
||||||
let after = top_row_span(&zoomed, w);
|
let after = top_row_span(&zoomed, w);
|
||||||
|
|
||||||
|
|||||||
+61
-19
@@ -22,7 +22,7 @@ import { SettingsPage } from "settings.slint";
|
|||||||
import { ImportPage } from "import.slint";
|
import { ImportPage } from "import.slint";
|
||||||
import { MergePage, MergeFrameRow } from "merge.slint";
|
import { MergePage, MergeFrameRow } from "merge.slint";
|
||||||
import { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint";
|
import { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint";
|
||||||
import { Capture, StatusBar, InfoPanel } from "develop.slint";
|
import { Capture, ProfileOffer, StatusBar, InfoPanel } from "develop.slint";
|
||||||
import { ToolRail } from "toolrail.slint";
|
import { ToolRail } from "toolrail.slint";
|
||||||
import { Keys } from "keys.slint";
|
import { Keys } from "keys.slint";
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
|||||||
// hooked through `window.global::<Adjustments>()` and only what this document
|
// hooked through `window.global::<Adjustments>()` and only what this document
|
||||||
// exports appears in the generated API. `session.slint` says why the develop
|
// exports appears in the generated API. `session.slint` says why the develop
|
||||||
// panels take their wiring this way and why there is one global per family.
|
// panels take their wiring this way and why there is one global per family.
|
||||||
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
export { Develop, Capture, ProfileOffer, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||||||
export { Collections }
|
export { Collections }
|
||||||
export { Library }
|
export { Library }
|
||||||
export { ExportOptions }
|
export { ExportOptions }
|
||||||
@@ -569,6 +569,7 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> import-volume-selected: -1;
|
in property <int> import-volume-selected: -1;
|
||||||
in property <string> import-card-path: "";
|
in property <string> import-card-path: "";
|
||||||
in property <bool> import-card-looks-right: false;
|
in property <bool> import-card-looks-right: false;
|
||||||
|
in property <bool> import-needs-access: false;
|
||||||
in property <bool> import-surveying: false;
|
in property <bool> import-surveying: false;
|
||||||
in property <string> import-survey-summary: "";
|
in property <string> import-survey-summary: "";
|
||||||
in property <string> import-upload-target: "";
|
in property <string> import-upload-target: "";
|
||||||
@@ -630,6 +631,7 @@ export component AppWindow inherits Window {
|
|||||||
callback import-card-path-changed(string);
|
callback import-card-path-changed(string);
|
||||||
callback import-choose-card();
|
callback import-choose-card();
|
||||||
callback import-refresh-volumes();
|
callback import-refresh-volumes();
|
||||||
|
callback import-grant-access();
|
||||||
callback import-template-changed(string);
|
callback import-template-changed(string);
|
||||||
callback import-mode-picked(int);
|
callback import-mode-picked(int);
|
||||||
callback import-duplicate-picked(int);
|
callback import-duplicate-picked(int);
|
||||||
@@ -1122,6 +1124,7 @@ in property <bool> panel-visible: true;
|
|||||||
volume-selected: root.import-volume-selected;
|
volume-selected: root.import-volume-selected;
|
||||||
card-path: root.import-card-path;
|
card-path: root.import-card-path;
|
||||||
card-looks-right: root.import-card-looks-right;
|
card-looks-right: root.import-card-looks-right;
|
||||||
|
needs-access: root.import-needs-access;
|
||||||
surveying: root.import-surveying;
|
surveying: root.import-surveying;
|
||||||
survey-summary: root.import-survey-summary;
|
survey-summary: root.import-survey-summary;
|
||||||
upload-target: root.import-upload-target;
|
upload-target: root.import-upload-target;
|
||||||
@@ -1145,6 +1148,7 @@ in property <bool> panel-visible: true;
|
|||||||
card-path-changed(t) => { root.import-card-path-changed(t); }
|
card-path-changed(t) => { root.import-card-path-changed(t); }
|
||||||
choose-card => { root.import-choose-card(); }
|
choose-card => { root.import-choose-card(); }
|
||||||
refresh-volumes() => { root.import-refresh-volumes(); }
|
refresh-volumes() => { root.import-refresh-volumes(); }
|
||||||
|
grant-access() => { root.import-grant-access(); }
|
||||||
template-changed(t) => { root.import-template-changed(t); }
|
template-changed(t) => { root.import-template-changed(t); }
|
||||||
mode-picked(i) => { root.import-mode-picked(i); }
|
mode-picked(i) => { root.import-mode-picked(i); }
|
||||||
duplicate-picked(i) => { root.import-duplicate-picked(i); }
|
duplicate-picked(i) => { root.import-duplicate-picked(i); }
|
||||||
@@ -1823,8 +1827,10 @@ in property <bool> panel-visible: true;
|
|||||||
clip: true;
|
clip: true;
|
||||||
|
|
||||||
canvas-image := Image {
|
canvas-image := Image {
|
||||||
width: 100%;
|
x: parent.fit-x;
|
||||||
height: 100%;
|
y: parent.fit-y;
|
||||||
|
width: parent.fit-w;
|
||||||
|
height: parent.fit-h;
|
||||||
source: root.canvas;
|
source: root.canvas;
|
||||||
image-fit: contain;
|
image-fit: contain;
|
||||||
// TRACES: FR-UI-4
|
// TRACES: FR-UI-4
|
||||||
@@ -1872,8 +1878,10 @@ in property <bool> panel-visible: true;
|
|||||||
// original straight away, and the last draft of the edit
|
// original straight away, and the last draft of the edit
|
||||||
// fades out over it rather than vanishing.
|
// fades out over it rather than vanishing.
|
||||||
previous-frame := Image {
|
previous-frame := Image {
|
||||||
width: 100%;
|
x: canvas-image.x;
|
||||||
height: 100%;
|
y: canvas-image.y;
|
||||||
|
width: canvas-image.width;
|
||||||
|
height: canvas-image.height;
|
||||||
source: root.canvas-previous;
|
source: root.canvas-previous;
|
||||||
image-fit: contain;
|
image-fit: contain;
|
||||||
image-rendering: canvas-image.image-rendering;
|
image-rendering: canvas-image.image-rendering;
|
||||||
@@ -1941,15 +1949,54 @@ in property <bool> panel-visible: true;
|
|||||||
// The overlay is placed against *this*, not against the whole
|
// The overlay is placed against *this*, not against the whole
|
||||||
// area — otherwise the crop rect drifts off the picture on any
|
// area — otherwise the crop rect drifts off the picture on any
|
||||||
// window whose shape differs from the image's.
|
// window whose shape differs from the image's.
|
||||||
|
//
|
||||||
|
// **While composing, the box is inset and stops above the
|
||||||
|
// photo roll.** A crop handle straddles the picture's edge,
|
||||||
|
// half of it outside, and a photograph fitted to the full
|
||||||
|
// box puts that half under this area's clip — and, along
|
||||||
|
// the bottom, under the roll's band, whose swipe handler
|
||||||
|
// takes every press inside it (see `PhotoRoll`). The bottom
|
||||||
|
// corners were drawn there, lit up, and could not be
|
||||||
|
// grabbed. Fitting the picture clear of both is the only
|
||||||
|
// fix: the handler has to be where it is, and moving the
|
||||||
|
// handles inward would leave them off the corner they
|
||||||
|
// move.
|
||||||
|
property <length> roll-band:
|
||||||
|
Library.library-total > 0
|
||||||
|
? (root.roll-open
|
||||||
|
? Theme.roll-strip + Theme.roll-reach
|
||||||
|
: Theme.roll-reach)
|
||||||
|
: 0px;
|
||||||
|
// The roll's own travel, so what stands on it rises with
|
||||||
|
// the strip rather than jumping ahead of it.
|
||||||
|
animate roll-band { duration: 180ms; easing: ease-out; }
|
||||||
|
//
|
||||||
|
// The floating "Done Composing" row stands on that band,
|
||||||
|
// and is declared after the crop overlay so it takes a
|
||||||
|
// press first: a photograph filling the width put the
|
||||||
|
// bottom-left handle under it. So the reserve is the band,
|
||||||
|
// the row's 12px margin, and the row's hit height — a
|
||||||
|
// `Button`'s press area is the touch target, not its ink.
|
||||||
|
// Every other side keeps half a handle and a finger's
|
||||||
|
// slop clear of this area's clip.
|
||||||
|
property <length> fit-inset: Develop.cropping ? 16px : 0px;
|
||||||
|
property <length> fit-below: Develop.cropping
|
||||||
|
? self.roll-band + 12px + Theme.touch-target
|
||||||
|
: 0px;
|
||||||
|
property <length> fit-x: self.fit-inset;
|
||||||
|
property <length> fit-y: self.fit-inset;
|
||||||
|
property <length> fit-w: max(1px, self.width - 2 * self.fit-inset);
|
||||||
|
property <length> fit-h: max(1px, self.height - 2 * self.fit-inset - self.fit-below);
|
||||||
|
|
||||||
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
|
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
|
||||||
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
|
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
|
||||||
property <float> box-w: self.width / 1px;
|
property <float> box-w: self.fit-w / 1px;
|
||||||
property <float> box-h: self.height / 1px;
|
property <float> box-h: self.fit-h / 1px;
|
||||||
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
|
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
|
||||||
property <length> shown-w: self.src-w * self.fit-scale * 1px;
|
property <length> shown-w: self.src-w * self.fit-scale * 1px;
|
||||||
property <length> shown-h: self.src-h * self.fit-scale * 1px;
|
property <length> shown-h: self.src-h * self.fit-scale * 1px;
|
||||||
property <length> shown-x: (self.width - self.shown-w) / 2;
|
property <length> shown-x: self.fit-x + (self.fit-w - self.shown-w) / 2;
|
||||||
property <length> shown-y: (self.height - self.shown-h) / 2;
|
property <length> shown-y: self.fit-y + (self.fit-h - self.shown-h) / 2;
|
||||||
|
|
||||||
// Empty and error states say what happened rather than
|
// Empty and error states say what happened rather than
|
||||||
// showing a blank canvas.
|
// showing a blank canvas.
|
||||||
@@ -2904,15 +2951,10 @@ in property <bool> panel-visible: true;
|
|||||||
// the thing that could not be reached. They stack
|
// the thing that could not be reached. They stack
|
||||||
// instead — the controls ride on top of the roll's
|
// instead — the controls ride on top of the roll's
|
||||||
// band, at whatever height it currently stands.
|
// band, at whatever height it currently stands.
|
||||||
property <length> roll-clear:
|
// The canvas's measure of the band, animated there
|
||||||
Library.library-total > 0
|
// with the roll's own travel so these rise with the
|
||||||
? (root.roll-open
|
// strip rather than jumping ahead of it.
|
||||||
? Theme.roll-strip + Theme.roll-reach
|
property <length> roll-clear: canvas-area.roll-band;
|
||||||
: Theme.roll-reach)
|
|
||||||
: 0px;
|
|
||||||
// The roll's own travel, so they rise with the strip
|
|
||||||
// rather than jumping ahead of it.
|
|
||||||
animate roll-clear { duration: 180ms; easing: ease-out; }
|
|
||||||
|
|
||||||
x: 12px;
|
x: 12px;
|
||||||
y: parent.height - self.preferred-height - 12px
|
y: parent.height - self.preferred-height - 12px
|
||||||
|
|||||||
+61
-36
@@ -37,15 +37,17 @@ export component CropOverlay inherits Rectangle {
|
|||||||
in property <float> crop-w: 1.0;
|
in property <float> crop-w: 1.0;
|
||||||
in property <float> crop-h: 1.0;
|
in property <float> crop-h: 1.0;
|
||||||
|
|
||||||
/// A dragged crop rect — `x`, `y`, `width`, `height`, then the corner the
|
/// A dragged crop rect — `x`, `y`, `width`, `height`, then the handle the
|
||||||
/// drag is holding as a `0`/`1` pair, or `-1, -1` where the whole rect is
|
/// drag is holding as a point of the rect: a `0`/`1` pair for a corner,
|
||||||
/// being moved and its shape must not be touched.
|
/// `0.5` on the axis an edge does not move, or `-1, -1` where the whole
|
||||||
|
/// rect is being moved and its shape must not be touched.
|
||||||
///
|
///
|
||||||
/// The corner is what an aspect lock needs and cannot infer: reshaping a
|
/// The handle is what an aspect lock needs and cannot infer: reshaping a
|
||||||
/// rect onto a ratio has to know which of its corners is nailed down, and
|
/// rect onto a ratio has to know which point of it is nailed down, and
|
||||||
/// only the handle that took the press knows that. It is reported as the
|
/// only the handle that took the press knows that. It is reported as the
|
||||||
/// held corner rather than as the fixed one because that is what is
|
/// held point rather than as the fixed one because that is what is
|
||||||
/// written here — Rust takes the opposite corner.
|
/// written here — Rust takes the opposite one, which for an edge is the
|
||||||
|
/// middle of the far side.
|
||||||
callback crop-changed(float, float, float, float, float, float);
|
callback crop-changed(float, float, float, float, float, float);
|
||||||
|
|
||||||
/// TRACES: FR-DEV-17
|
/// TRACES: FR-DEV-17
|
||||||
@@ -189,24 +191,43 @@ export component CropOverlay inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Corner handles. Each drags one corner while the opposite
|
// Handles: the four edges, then the four corners.
|
||||||
// stays put, which is the only behaviour that lets a crop
|
//
|
||||||
// be shaped rather than merely moved.
|
// A corner drags one corner while the opposite stays put, which is the
|
||||||
for corner in [
|
// only behaviour that lets a crop be shaped rather than merely moved. An
|
||||||
|
// edge drags that one side along the axis across it and leaves the other
|
||||||
|
// three where they are — the way to trim a strip off one side without
|
||||||
|
// disturbing the corner you had already placed.
|
||||||
|
//
|
||||||
|
// Each is a point of the rect in its own `0..1` coordinates; `0.5` on an
|
||||||
|
// axis is an edge's midpoint and means "this axis does not move". The
|
||||||
|
// edges come first because among overlapping siblings the last declared
|
||||||
|
// takes the press, and on a rect dragged small the corners are the ones
|
||||||
|
// that must stay reachable.
|
||||||
|
for handle in [
|
||||||
|
{ hx: 0.5, hy: 0.0 },
|
||||||
|
{ hx: 0.5, hy: 1.0 },
|
||||||
|
{ hx: 0.0, hy: 0.5 },
|
||||||
|
{ hx: 1.0, hy: 0.5 },
|
||||||
{ hx: 0.0, hy: 0.0 },
|
{ hx: 0.0, hy: 0.0 },
|
||||||
{ hx: 1.0, hy: 0.0 },
|
{ hx: 1.0, hy: 0.0 },
|
||||||
{ hx: 0.0, hy: 1.0 },
|
{ hx: 0.0, hy: 1.0 },
|
||||||
{ hx: 1.0, hy: 1.0 },
|
{ hx: 1.0, hy: 1.0 },
|
||||||
]: Rectangle {
|
]: Rectangle {
|
||||||
property <length> size: 18px;
|
property <bool> across: handle.hx == 0.5;
|
||||||
x: parent.rx + corner.hx * parent.rw - self.size / 2;
|
property <bool> along: handle.hy == 0.5;
|
||||||
y: parent.ry + corner.hy * parent.rh - self.size / 2;
|
// A corner is a square; an edge is a bar lying along its side, with
|
||||||
width: self.size;
|
// a hit area as long as the bar and as deep as a corner's.
|
||||||
height: self.size;
|
property <length> grip: 18px;
|
||||||
|
property <length> reach: 36px;
|
||||||
|
width: self.across ? self.reach : self.grip;
|
||||||
|
height: self.along ? self.reach : self.grip;
|
||||||
|
x: parent.rx + handle.hx * parent.rw - self.width / 2;
|
||||||
|
y: parent.ry + handle.hy * parent.rh - self.height / 2;
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 12px;
|
width: parent.across ? 24px : (parent.along ? 5px : 12px);
|
||||||
height: 12px;
|
height: parent.along ? 24px : (parent.across ? 5px : 12px);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
@@ -214,9 +235,13 @@ export component CropOverlay inherits Rectangle {
|
|||||||
TouchArea {
|
TouchArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
mouse-cursor: (corner.hx == corner.hy)
|
mouse-cursor: parent.across
|
||||||
? MouseCursor.nwse-resize
|
? MouseCursor.ns-resize
|
||||||
: MouseCursor.nesw-resize;
|
: parent.along
|
||||||
|
? MouseCursor.ew-resize
|
||||||
|
: (handle.hx == handle.hy)
|
||||||
|
? MouseCursor.nwse-resize
|
||||||
|
: MouseCursor.nesw-resize;
|
||||||
|
|
||||||
property <float> ox;
|
property <float> ox;
|
||||||
property <float> oy;
|
property <float> oy;
|
||||||
@@ -246,27 +271,27 @@ export component CropOverlay inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movement as a fraction of the frame, live while
|
// Movement as a fraction of the frame, live while the handle is
|
||||||
// the handle is held.
|
// held — and nothing along an axis the handle does not move, so
|
||||||
property <float> dx: self.fraction-x(self.mouse-x) - self.from-x;
|
// an edge dragged at a slant still only moves its own side.
|
||||||
property <float> dy: self.fraction-y(self.mouse-y) - self.from-y;
|
property <float> dx: parent.across ? 0 : self.fraction-x(self.mouse-x) - self.from-x;
|
||||||
|
property <float> dy: parent.along ? 0 : self.fraction-y(self.mouse-y) - self.from-y;
|
||||||
|
|
||||||
moved => {
|
moved => {
|
||||||
if (!self.pressed) {
|
if (!self.pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Dragging a left/top handle moves the origin
|
// Dragging a left/top handle moves the origin and shrinks the
|
||||||
// and shrinks the extent by the same amount;
|
// extent by the same amount; a right/bottom handle moves only
|
||||||
// a right/bottom handle moves only the extent.
|
// the extent. Rust clamps the result, so an over-drag slides
|
||||||
// Rust clamps the result, so an over-drag
|
// rather than inverting.
|
||||||
// slides rather than inverting.
|
|
||||||
root.crop-changed(
|
root.crop-changed(
|
||||||
corner.hx == 0 ? self.ox + self.dx : self.ox,
|
handle.hx == 0 ? self.ox + self.dx : self.ox,
|
||||||
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
handle.hy == 0 ? self.oy + self.dy : self.oy,
|
||||||
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
handle.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||||
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
handle.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||||
corner.hx,
|
handle.hx,
|
||||||
corner.hy,
|
handle.hy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,10 +383,51 @@ export global Capture {
|
|||||||
/// same kind of thing as the body and the exposure, and it wants reading
|
/// same kind of thing as the body and the exposure, and it wants reading
|
||||||
/// once on opening rather than hunting for under a group filter.
|
/// once on opening rather than hunting for under a group filter.
|
||||||
in property <string> lens;
|
in property <string> lens;
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The camera profile the photograph renders through, and where it was
|
||||||
|
/// found — or that there is none (D20). A fact about the file and the
|
||||||
|
/// installed profiles, read once on opening, as the lens line is.
|
||||||
|
in property <string> profile;
|
||||||
in property <string> exposure;
|
in property <string> exposure;
|
||||||
in property <string> dimensions;
|
in property <string> dimensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The one action the info panel offers: saving the camera profile embedded
|
||||||
|
/// in this file for every photograph from the same body (D20).
|
||||||
|
///
|
||||||
|
/// A global of its own because `Capture` only reports and carries no
|
||||||
|
/// callback. `offer` is the wording, naming the body; empty when there is
|
||||||
|
/// nothing to offer, which is every file but a DNG whose profile may be
|
||||||
|
/// copied and is not installed yet.
|
||||||
|
export global ProfileOffer {
|
||||||
|
in property <string> offer;
|
||||||
|
callback adopt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offer as a line of text that acts, rather than a button: a button
|
||||||
|
// does not wrap, and a label naming a camera is long enough to widen the
|
||||||
|
// whole column.
|
||||||
|
component OfferLine inherits Rectangle {
|
||||||
|
in property <string> text;
|
||||||
|
callback clicked();
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: root.text;
|
||||||
|
accessible-action-default => { root.clicked(); }
|
||||||
|
height: line.preferred-height;
|
||||||
|
|
||||||
|
line := Caption {
|
||||||
|
width: 100%;
|
||||||
|
text: root.text + " →";
|
||||||
|
wrap: word-wrap;
|
||||||
|
emphasised: touch.has-hover;
|
||||||
|
}
|
||||||
|
touch := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
||||||
// which is generated from pipeline capabilities rather than written here.
|
// which is generated from pipeline capabilities rather than written here.
|
||||||
//
|
//
|
||||||
@@ -424,6 +465,17 @@ export component InfoPanel inherits Rectangle {
|
|||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: Capture.profile;
|
||||||
|
visible: Capture.profile != "";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ProfileOffer.offer != "": OfferLine {
|
||||||
|
text: ProfileOffer.offer;
|
||||||
|
clicked => { ProfileOffer.adopt(); }
|
||||||
|
}
|
||||||
|
|
||||||
Caption { text: Capture.dimensions; }
|
Caption { text: Capture.dimensions; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,17 @@ export component ImportPage inherits Rectangle {
|
|||||||
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
||||||
/// sources do not, so this informs and never blocks.
|
/// sources do not, so this informs and never blocks.
|
||||||
in property <bool> card-looks-right: false;
|
in property <bool> card-looks-right: false;
|
||||||
|
/// Android: the app may not read a card until the user allows "all files
|
||||||
|
/// access", which is a switch in the system settings, not a dialog.
|
||||||
|
in property <bool> needs-access: false;
|
||||||
|
|
||||||
callback volume-picked(int);
|
callback volume-picked(int);
|
||||||
callback card-path-changed(string);
|
callback card-path-changed(string);
|
||||||
/// Browse for the source with the platform's dialogue.
|
/// Browse for the source with the platform's dialogue.
|
||||||
callback choose-card();
|
callback choose-card();
|
||||||
callback refresh-volumes();
|
callback refresh-volumes();
|
||||||
|
/// Open the system page where that switch is.
|
||||||
|
callback grant-access();
|
||||||
|
|
||||||
// --- what is on it ---------------------------------------------------
|
// --- what is on it ---------------------------------------------------
|
||||||
in property <bool> surveying: false;
|
in property <bool> surveying: false;
|
||||||
@@ -222,10 +227,27 @@ export component ImportPage inherits Rectangle {
|
|||||||
Panel {
|
Panel {
|
||||||
PanelHeading { text: "From"; }
|
PanelHeading { text: "From"; }
|
||||||
|
|
||||||
if root.volume-labels.length == 0: Caption {
|
// Android, before the grant. Said once, with the one
|
||||||
|
// control that fixes it, instead of an empty list that
|
||||||
|
// would read as "no card inserted".
|
||||||
|
if root.needs-access: Caption {
|
||||||
|
text: "To read a camera card, DarkRoom needs \"All files access\". Allow it on the next screen, then come back here.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
if root.needs-access: HorizontalLayout {
|
||||||
|
Button {
|
||||||
|
text: "Allow access";
|
||||||
|
primary: true;
|
||||||
|
enabled: !root.running;
|
||||||
|
clicked => { root.grant-access(); }
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !root.needs-access && root.volume-labels.length == 0: Caption {
|
||||||
text: Pickers.local-paths
|
text: Pickers.local-paths
|
||||||
? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh."
|
? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh."
|
||||||
: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
|
: "No SD card or card reader found. Insert one and press Refresh.";
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import { Icon } from "icons.slint";
|
|||||||
import { ViewMode } from "session.slint";
|
import { ViewMode } from "session.slint";
|
||||||
import { PresetRow, PresetOrigin, PresetFolderRow } from "presets.slint";
|
import { PresetRow, PresetOrigin, PresetFolderRow } from "presets.slint";
|
||||||
import { Label, ScrollBar } from "widgets.slint";
|
import { Label, ScrollBar } from "widgets.slint";
|
||||||
|
import { Keys } from "keys.slint";
|
||||||
|
|
||||||
// One tool. A struct rather than four parallel arrays so a row cannot be
|
// One tool. A struct rather than four parallel arrays so a row cannot be
|
||||||
// half-added — the compiler will not let a new entry omit its icon.
|
// half-added — the compiler will not let a new entry omit its icon.
|
||||||
@@ -522,6 +523,30 @@ export component ToolRail inherits Rectangle {
|
|||||||
root.height - Theme.gap);
|
root.height - Theme.gap);
|
||||||
close-policy: PopupClosePolicy.close-on-click-outside;
|
close-policy: PopupClosePolicy.close-on-click-outside;
|
||||||
|
|
||||||
|
// Somewhere for Back to start from while the menu is up.
|
||||||
|
//
|
||||||
|
// A popup is its own focus tree: showing it takes focus off the
|
||||||
|
// develop view, and Slint hands it back only when the popup
|
||||||
|
// closes. With nothing focusable in here, Android's Back gesture
|
||||||
|
// — the natural way to dismiss a menu — found no focus item,
|
||||||
|
// went unanswered, and the platform closed the application.
|
||||||
|
// Slint closes a popup on Escape by itself but knows nothing of
|
||||||
|
// Back, so both are answered here, as the film list does.
|
||||||
|
FocusScope {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
init => { self.focus(); }
|
||||||
|
|
||||||
|
// KEYMAP: Develop
|
||||||
|
key-pressed(event) => {
|
||||||
|
if (Keys.chord(event) == "Escape" || Keys.chord(event) == "Back") {
|
||||||
|
preset-menu.close();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
|
|||||||
Reference in New Issue
Block a user