Compare commits
35
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 | ||
|
|
5ae742816d | ||
|
|
8d66fa9be5 | ||
|
|
34630ff752 | ||
|
|
3b7d7129ff | ||
|
|
6050a8e703 | ||
|
|
ae4e1a0f07 | ||
|
|
ce5b7d72e3 | ||
|
|
98a67393d9 | ||
|
|
37136f7377 | ||
|
|
e2e2181469 | ||
|
|
ad27369cdc |
Generated
+25
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ members = [
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
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 |
|
||||
| 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
|
||||
pins itself to 1.92.0:
|
||||
## Building from source
|
||||
|
||||
**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
|
||||
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
./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
|
||||
contribution — a develop operation is one YAML file, and it arrives with its
|
||||
controls, its place in the chain and its tests.
|
||||
|
||||
## Where it stands
|
||||
|
||||
**0.19.0**, twenty-nine 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);
|
||||
the rest are written down rather than merely absent.
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||
here is a distribution manifest yet. Only network access is declared: file
|
||||
access needs no manifest permission because the library grid reads through
|
||||
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||
here is a distribution manifest yet. The library grid needs no storage
|
||||
permission, because it reads through SAF, which grants per-tree at runtime
|
||||
(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
|
||||
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
|
||||
socket creation outright, and the failure is invisible — no panic to
|
||||
catch, no log line, just a worker thread that stops. Storage is the
|
||||
separate case that genuinely needs no permission here, because SAF
|
||||
grants per-tree at runtime (ARCH §6.9). -->
|
||||
separate case: the library and album folders need no permission
|
||||
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||
work on unmetered-and-charging, which means knowing the network type. -->
|
||||
<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
|
||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||
fails at install rather than at first frame. -->
|
||||
@@ -53,6 +70,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="true"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- 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::time::{Duration, Instant};
|
||||
|
||||
use dr_catalog::{keywords, rating, schema, Catalog};
|
||||
use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
|
||||
|
||||
fn main() {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -108,6 +108,9 @@ fn main() {
|
||||
time(" keywords::adopt_orphan_terms", 20, || {
|
||||
keywords::adopt_orphan_terms(conn).unwrap();
|
||||
});
|
||||
time(" name_dates::fill", 20, || {
|
||||
name_dates::fill(conn, None).unwrap();
|
||||
});
|
||||
|
||||
interactive(conn);
|
||||
|
||||
|
||||
@@ -306,6 +306,48 @@ pub fn record_exports(
|
||||
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 —
|
||||
/// what the grid shows when the album is opened.
|
||||
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]);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn moving_to_the_server_forgets_the_local_folder() {
|
||||
let cat = catalog();
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod faces;
|
||||
pub mod jobs;
|
||||
pub mod keywords;
|
||||
pub mod merge;
|
||||
pub mod name_dates;
|
||||
pub mod query;
|
||||
pub mod rating;
|
||||
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));
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||
|
||||
pub mod dcp;
|
||||
mod decoder;
|
||||
mod error;
|
||||
mod locate;
|
||||
@@ -137,8 +138,17 @@ pub struct RawImage {
|
||||
/// carry on: calibrations and the as-shot neutral. `None` for a body the
|
||||
/// decoder has no matrix for.
|
||||
pub profile: Option<profile::CameraProfile>,
|
||||
/// The body, as rawler cleans the names: what `Make`/`Model` say and what
|
||||
/// the base-curve database matches on.
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// 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 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
|
||||
// both callers ask the same object for it.
|
||||
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 wb_coeffs = sane_wb(
|
||||
image.wb_coeffs,
|
||||
@@ -661,6 +688,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
color_matrix,
|
||||
samples_per_pixel,
|
||||
profile,
|
||||
profile_tables,
|
||||
make: image.camera.clean_make.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
|
||||
/// under fluorescent light, and placing it at roughly the right colour is much
|
||||
/// better than discarding it.
|
||||
fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
||||
pub(crate) fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
||||
Some(match illuminant {
|
||||
// CIE standard illuminant A: a tungsten filament at 2856 K. The low
|
||||
// end of essentially every dual-illuminant profile ever written.
|
||||
|
||||
@@ -24,7 +24,7 @@ fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(input) = args.next() else {
|
||||
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);
|
||||
};
|
||||
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(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
|
||||
// moving.
|
||||
"contrast" => {
|
||||
@@ -109,6 +127,14 @@ fn main() {
|
||||
graph.set_param(curve::ID, curve::P0_Y, 0.12);
|
||||
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 (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();
|
||||
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
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
@@ -134,8 +167,11 @@ fn main() {
|
||||
// path, and it must not recompile.
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
|
||||
let again = graph.compose();
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
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
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
|
||||
@@ -71,6 +71,14 @@ pub struct AdjustPass {
|
||||
empty_film_lut: wgpu::TextureView,
|
||||
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
|
||||
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
|
||||
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
|
||||
/// 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.
|
||||
/// 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 {
|
||||
self.film
|
||||
.as_ref()
|
||||
@@ -645,6 +684,8 @@ impl AdjustPass {
|
||||
empty_film_curves,
|
||||
empty_film_lut,
|
||||
film: None,
|
||||
empty_profile: Self::upload_profile(ctx, None),
|
||||
profile: None,
|
||||
detail: DetailRunner::new(ctx),
|
||||
linear_bind_group_layout,
|
||||
linear_pipeline_layout,
|
||||
@@ -775,6 +816,17 @@ impl AdjustPass {
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
let profile = self.profile_buffer(source);
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&shader.structure_hash)
|
||||
@@ -1020,6 +1073,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
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.
|
||||
let film_curves = self.film_curves_view().clone();
|
||||
let film_lut = self.film_lut_view().clone();
|
||||
let profile = self.profile_buffer(source);
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
@@ -1202,6 +1260,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline = self
|
||||
@@ -1298,6 +1360,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
@@ -1609,6 +1675,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct DemosaicedImage {
|
||||
height: u32,
|
||||
/// Carried through for the camera→sRGB transform in the adjust pass.
|
||||
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_wb: [f32; 3],
|
||||
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||
@@ -207,6 +212,12 @@ impl DemosaicedImage {
|
||||
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.
|
||||
///
|
||||
/// The white balance control is expressed *relative* to these, so its
|
||||
@@ -318,6 +329,7 @@ impl DemosaicedImage {
|
||||
width,
|
||||
height,
|
||||
color_matrix: IDENTITY_3X3,
|
||||
profile_tables: None,
|
||||
as_shot_wb: [1.0, 1.0, 1.0],
|
||||
// **The identity, and this is the whole reason the field is here
|
||||
// rather than resolved further down.** A JPEG has already been
|
||||
@@ -478,6 +490,7 @@ impl DemosaicedImage {
|
||||
width,
|
||||
height,
|
||||
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]],
|
||||
non_linear: false,
|
||||
id: next_image_id(),
|
||||
@@ -892,6 +905,7 @@ impl Demosaicer {
|
||||
// Identity where the body is uncalibrated: the image renders with
|
||||
// no colour transform rather than not at all.
|
||||
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]],
|
||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||
// resolved at decode because that is the only place the make and
|
||||
@@ -1399,6 +1413,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1514,6 +1529,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1810,6 +1826,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1894,6 +1911,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
|
||||
+109
-8
@@ -30,18 +30,27 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! # 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
|
||||
//!
|
||||
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
|
||||
//! distance to the frame's edge, which hides exposure steps and small
|
||||
//! misalignments and does not hide parallax. Gain is a scalar per frame
|
||||
//! the caller supplies. Both are panorama.md §10's step 5, after the path
|
||||
//! writes a file end to end.
|
||||
//! A Laplacian pyramid, which would let the seam's blend be narrow for
|
||||
//! detail and wide for exposure at once. Gain is a scalar per frame the
|
||||
//! caller supplies.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{Bounds, Projection};
|
||||
use dr_pano::seam::SeamMap;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::readback::await_mapping;
|
||||
@@ -58,7 +67,7 @@ pub struct MergeFrame {
|
||||
}
|
||||
|
||||
/// The output the merge produces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MergeOutput {
|
||||
pub projection: Projection,
|
||||
/// The projection's scale in output pixels: the cylinder's radius, the
|
||||
@@ -69,6 +78,11 @@ pub struct MergeOutput {
|
||||
pub bounds: Bounds,
|
||||
/// Pixels over which a frame's weight ramps up from its edge.
|
||||
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.
|
||||
pub chunk: (u32, u32),
|
||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||
@@ -115,6 +129,12 @@ struct WarpParams {
|
||||
feather: f32,
|
||||
clip_onset: f32,
|
||||
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)]
|
||||
@@ -182,6 +202,16 @@ impl MergePass {
|
||||
count: None,
|
||||
},
|
||||
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 =
|
||||
@@ -279,6 +309,32 @@ impl MergePass {
|
||||
let mut band_cov = vec![false; (out_w * ch) as usize];
|
||||
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;
|
||||
while y < out_h {
|
||||
let rows = ch.min(out_h - y);
|
||||
@@ -346,8 +402,14 @@ impl MergePass {
|
||||
output.balance[2].max(1e-3),
|
||||
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)?;
|
||||
@@ -385,7 +447,42 @@ impl MergePass {
|
||||
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 uniforms = self
|
||||
.ctx
|
||||
@@ -417,6 +514,10 @@ impl MergePass {
|
||||
binding: 2,
|
||||
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());
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// pixel it asks which direction that pixel looks along, turns the
|
||||
// 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,
|
||||
// samples it and adds it — weighted by its distance from the frame's edge
|
||||
// — into the accumulator. `resolve` runs once per chunk after every frame
|
||||
// samples it and adds it — weighted by the frame's share of the seam map
|
||||
// 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
|
||||
// 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.
|
||||
clip_onset: 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(1) var tile: texture_2d<f32>;
|
||||
// rgb·w summed, then w: four floats per chunk pixel.
|
||||
@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> {
|
||||
let s = p.proj_scale;
|
||||
@@ -98,7 +170,17 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (edge <= 0.0) {
|
||||
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.
|
||||
let tx = sx - p.tile_origin.x;
|
||||
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.
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! - [`align`] — the whole thing, from features to cameras, honest about
|
||||
//! what it could not place.
|
||||
//! - [`projection`] — perspective, cylindrical, spherical.
|
||||
//! - [`seam`] — which frame each output pixel is taken from.
|
||||
//! - [`linalg`] — the small dense algebra all of it uses.
|
||||
//!
|
||||
//! # What it depends on
|
||||
@@ -43,6 +44,7 @@ pub mod matching;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod migan;
|
||||
pub mod projection;
|
||||
pub mod seam;
|
||||
#[cfg(feature = "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 image::Gray;
|
||||
pub use projection::Projection;
|
||||
pub use seam::{SeamMap, SeamOptions};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
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"),
|
||||
),
|
||||
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
||||
("vivid", "Vivid", include_str!("../presets/vivid.drpl")),
|
||||
(
|
||||
"colour_film",
|
||||
"Film/Colour",
|
||||
|
||||
@@ -260,8 +260,9 @@ impl CropRect {
|
||||
///
|
||||
/// `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,
|
||||
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
|
||||
/// a ratio is chosen and the composition should stay where it is.
|
||||
/// so the far corner is the one that does not move, `(0.0, 0.5)` while
|
||||
/// 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
|
||||
/// 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,
|
||||
/// 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.
|
||||
/// 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 {
|
||||
let rect = self.normalised();
|
||||
let ratio = finite(ratio, 0.0);
|
||||
@@ -286,8 +288,19 @@ impl CropRect {
|
||||
let px = rect.x + ax * rect.width;
|
||||
let py = rect.y + ay * rect.height;
|
||||
|
||||
let mut w = rect.width.max(rect.height * r);
|
||||
let mut h = w / r;
|
||||
// An anchor in the middle of one side is an *edge* being dragged, and
|
||||
// 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
|
||||
// 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:?}");
|
||||
}
|
||||
|
||||
#[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]
|
||||
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
|
||||
|
||||
@@ -1170,19 +1170,21 @@ mod tests {
|
||||
// Opening an unedited image must produce the image, not an
|
||||
// interpretation of it.
|
||||
//
|
||||
// One block, and it is the view transform: a view operation is
|
||||
// composed at its defaults, because a photograph with no view
|
||||
// transform is a scan rather than a picture (FR-DEV-3j). It is still
|
||||
// neutral in the sense that matters here — nothing moved, nothing is
|
||||
// written — and every adjustment is absent.
|
||||
// Two blocks, the view transform and the camera profile: both are
|
||||
// composed at their defaults, because a photograph with no view
|
||||
// transform is a scan rather than a picture (FR-DEV-3j) and a raw
|
||||
// with a profile is rendered through it (D20). They are still neutral
|
||||
// in the sense that matters here — nothing moved, nothing is written
|
||||
// — and every adjustment is absent.
|
||||
let g = EditGraph::default_chain();
|
||||
assert!(g.is_neutral());
|
||||
let source = g.compose().source;
|
||||
assert_eq!(
|
||||
source.matches("---- ").count(),
|
||||
1,
|
||||
2,
|
||||
"a neutral graph must generate no adjustment blocks"
|
||||
);
|
||||
assert!(source.contains("---- camera_profile ----"));
|
||||
assert!(source.contains("---- view_transform ----"));
|
||||
}
|
||||
|
||||
@@ -1263,13 +1265,14 @@ mod tests {
|
||||
fn only_active_operations_reach_the_shader() {
|
||||
// The composition property, end to end: two adjustments out of seven
|
||||
// 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();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||
|
||||
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("---- exposure ----"));
|
||||
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
|
||||
// needs a stock's measured tables, which are not parameters and which
|
||||
// no slider produces. So it is loaded explicitly here.
|
||||
|
||||
@@ -270,6 +270,19 @@ pub trait Operation: Send + Sync {
|
||||
/// what is actually used.
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
.iter()
|
||||
.map(|o| o.as_ref())
|
||||
.filter(|o| o.is_active() && o.detail().is_none())
|
||||
.filter(|o| o.composes() && o.detail().is_none())
|
||||
.collect();
|
||||
|
||||
// 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();
|
||||
// The global side of the blend. A view operation always has one; see
|
||||
// `Stage::View`.
|
||||
let global = op.is_active() || op.stage() == Stage::View;
|
||||
let global = op.composes() || op.stage() == Stage::View;
|
||||
if !global && local.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -1333,6 +1346,11 @@ struct Params {{
|
||||
// bound to 1x1 placeholders whenever the flags say not to touch them.
|
||||
@group(0) @binding(6) var sampled: texture_2d<f32>;
|
||||
@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}
|
||||
// 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
|
||||
// places it in the chain; these are the implementations that entry points at.
|
||||
pub mod aberration;
|
||||
pub mod camera_profile;
|
||||
pub mod capture_sharpen;
|
||||
pub mod colour_mixer;
|
||||
pub mod curve;
|
||||
@@ -78,6 +79,7 @@ pub mod view_transform;
|
||||
pub mod vignetting;
|
||||
|
||||
pub use aberration::Aberration;
|
||||
pub use camera_profile::CameraProfile;
|
||||
pub use capture_sharpen::CaptureSharpen;
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use curve::ToneCurve;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
//!
|
||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||
//! `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
|
||||
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||
@@ -396,6 +396,7 @@ fn every_declared_node_is_checked() {
|
||||
assert_eq!(
|
||||
hand,
|
||||
[
|
||||
"camera_profile",
|
||||
"capture_sharpen",
|
||||
"clarity",
|
||||
"colour_mixer",
|
||||
|
||||
@@ -72,17 +72,54 @@ pub enum ThumbSize {
|
||||
/// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only
|
||||
/// where something actually asks for that detail.
|
||||
Large = 1,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// A panorama's cell two columns wide, at the height of one: long edge
|
||||
/// sized for the width rather than for a square, since the grid class
|
||||
/// of a 4:1 panorama is 256×64 — a smear across the cells. The wide
|
||||
/// classes are made only for photographs that wide, so they cost a
|
||||
/// library nothing else.
|
||||
Wide2 = 2,
|
||||
/// Three columns.
|
||||
Wide3 = 3,
|
||||
/// Four columns: the widest class.
|
||||
Wide4 = 4,
|
||||
}
|
||||
|
||||
/// The most columns a wide class spans.
|
||||
pub const WIDEST_SPAN: usize = 4;
|
||||
|
||||
impl ThumbSize {
|
||||
/// Long edge in pixels.
|
||||
/// Long edge in pixels. A wide class is 512 per column it spans, which
|
||||
/// keeps its short edge near the large class's for the aspect that
|
||||
/// class is chosen for — sharp at the largest cells on a 2x display.
|
||||
pub fn edge(self) -> u32 {
|
||||
match self {
|
||||
ThumbSize::Grid => 256,
|
||||
ThumbSize::Large => 1024,
|
||||
ThumbSize::Wide2 => 1024,
|
||||
ThumbSize::Wide3 => 1536,
|
||||
ThumbSize::Wide4 => 2048,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wide class for a cell `span` columns wide: `None` for one
|
||||
/// column, and the widest class for anything past it.
|
||||
pub fn wide(span: usize) -> Option<Self> {
|
||||
match span {
|
||||
0 | 1 => None,
|
||||
2 => Some(ThumbSize::Wide2),
|
||||
3 => Some(ThumbSize::Wide3),
|
||||
_ => Some(ThumbSize::Wide4),
|
||||
}
|
||||
}
|
||||
|
||||
/// The class for a cell `span` columns wide whose columns are drawn at
|
||||
/// `pixels`: a wide class for any cell wider than one, whatever the
|
||||
/// zoom, since its height is a column's and its width is not.
|
||||
pub fn for_span(span: usize, pixels: u32) -> Self {
|
||||
Self::wide(span).unwrap_or_else(|| Self::for_cell(pixels))
|
||||
}
|
||||
|
||||
/// The smallest class that can fill a cell of this size without visibly
|
||||
/// softening.
|
||||
///
|
||||
@@ -96,12 +133,22 @@ impl ThumbSize {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_i64(v: i64) -> Self {
|
||||
/// The class a stored discriminant names, or `None` for one this build
|
||||
/// does not know.
|
||||
pub fn from_stored(v: i64) -> Option<Self> {
|
||||
match v {
|
||||
1 => ThumbSize::Large,
|
||||
_ => ThumbSize::Grid,
|
||||
0 => Some(ThumbSize::Grid),
|
||||
1 => Some(ThumbSize::Large),
|
||||
2 => Some(ThumbSize::Wide2),
|
||||
3 => Some(ThumbSize::Wide3),
|
||||
4 => Some(ThumbSize::Wide4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_i64(v: i64) -> Self {
|
||||
Self::from_stored(v).unwrap_or(ThumbSize::Grid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Long edge of a grid thumbnail.
|
||||
|
||||
@@ -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;
|
||||
|
||||
pub mod colour;
|
||||
pub mod hue_sat;
|
||||
pub mod place;
|
||||
pub mod selector;
|
||||
pub mod settings;
|
||||
pub mod time;
|
||||
|
||||
pub use colour::{Chromaticities, Transfer};
|
||||
pub use hue_sat::{HueSatTable, ProfileOrigin, ProfileTables};
|
||||
pub use place::{Place, PlaceScope, Screen, StoredFilter};
|
||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||
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.
|
||||
@@ -0,0 +1,358 @@
|
||||
# Learned denoise — joint demosaic and denoise on the mosaic
|
||||
|
||||
Design for **FR-DEV-3g** ([requirements.md](requirements.md)), the learned stage
|
||||
[outstanding.md §3](outstanding.md) says is missing. Draft of 2026-09-27: nothing here is built,
|
||||
and every figure marked *estimate* is waiting for the measurement that replaces it.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we are matching
|
||||
|
||||
Lightroom's Denoise (April 2023, Eric Chan's "Denoise demystified") is the reference, and three
|
||||
facts about it set the shape of this design:
|
||||
|
||||
- **It runs on the mosaic.** The network takes Bayer or X-Trans photosites before any demosaic
|
||||
and emits full RGB: denoise and demosaic are one learned step. It descends from Adobe's 2019
|
||||
learned demosaic (Raw Details). A photograph that is already demosaiced is not eligible.
|
||||
- **It is run once, not per frame.** The result is written as a new linear DNG beside the
|
||||
original, and every later edit reads that file. The amount is chosen once, from a preview crop.
|
||||
- **It is trained on synthetic pairs.** Clean raws with sensor-modelled noise added, not
|
||||
photographed pairs.
|
||||
|
||||
The reason the mosaic is the right place is physical: before the demosaic, noise is independent
|
||||
per photosite with a known distribution (shot plus read). After it, the interpolation has
|
||||
correlated that noise into colour blotches many photosites across, which classical noise reduction
|
||||
cannot separate from texture. The same step removes demosaic artefacts
|
||||
— maze, zipper, false colour, X-Trans worms (FR-RAW-5).
|
||||
|
||||
We match the first and third facts and not the second: our result is a cache, not a file in the
|
||||
library (§7).
|
||||
|
||||
## 2. Where it sits
|
||||
|
||||
[architecture.md §5.2](architecture.md) already reserves the slot. The learned stage **replaces
|
||||
the demosaic box** when it is on; nothing else in the chain moves.
|
||||
|
||||
```
|
||||
RawImage ─► hot/dead photosites ─► black/white levels ─► ┬─ demosaic (classical) ─┬─► camera profile ─► …
|
||||
└─ learned demosaic+NR ──┘
|
||||
(cached, §7)
|
||||
```
|
||||
|
||||
- **In:** the repaired, normalised mosaic, from the same buffer `Demosaicer::run` reads. The hot
|
||||
pixel pass stays in front: an outlier of 50σ is outside anything the noise model generates, and
|
||||
a network shown one invents a structure around it.
|
||||
- **Out:** linear camera RGB, f16, full resolution — exactly the texture the classical demosaic
|
||||
produces, so the camera profile, the raw histogram and every operation below it are unchanged.
|
||||
- **Off by default, per photograph.** The classical path stays the default and the fallback; the
|
||||
stage's absence degrades gracefully, as FR-DEV-3g requires.
|
||||
|
||||
## 3. The model
|
||||
|
||||
### 3.1 The 12×12 → 4×4 question
|
||||
|
||||
The proposal: a network that reads a 12×12 window of photosites and predicts the RGB of the
|
||||
central 4×4, slid across the frame in steps of four.
|
||||
|
||||
**The output half is right. The input half is too small by a factor of five or more.**
|
||||
|
||||
*What is right about it.* Predicting a block aligned to the colour-filter period keeps the phase
|
||||
fixed: every prediction sees the same arrangement of red, green and blue around it, so the network
|
||||
never has to work out where it stands in the pattern. It also makes tiling trivial and exact.
|
||||
Both properties are kept below — as the head of the network and as the tiling contract (§3.4).
|
||||
|
||||
*What is wrong with it.* A denoiser can only average away noise it can see around the pixel, and
|
||||
at high ISO it needs to see a long way:
|
||||
|
||||
- The Canon 6D at ISO 6400 (clip ≈ 1,200 e⁻, read noise ≈ 2 e⁻ — *estimate*, §5 measures it) has a
|
||||
mid-tone of ~150 e⁻, shot SNR ≈ 12, and a shadow three stops down of ~19 e⁻, SNR ≈ 4.
|
||||
- A shadow that looks clean wants SNR ≈ 40: a factor of 10, which is ~100 independent same-colour
|
||||
samples in a flat area. Red and blue are a quarter of the photosites, so that is ~400
|
||||
photosites: a **20×20 window just for a flat shadow**, 40×40 two stops further down.
|
||||
- A 12×12 window holds 36 red photosites. Averaged perfectly, that is a factor of 6 on red and
|
||||
blue in a flat area, and less everywhere there is structure.
|
||||
- Chroma blotches are low-frequency noise — 16 to 64 photosites across. A window smaller than the
|
||||
blotch cannot tell it from a colour change.
|
||||
|
||||
Demosaic alone is content with 12×12: good classical demosaics read 5×5 to 9×9. So the proposal is
|
||||
a good demosaic network and a weak denoiser — which is a useful ablation (experiment E1, §6.3).
|
||||
|
||||
*What it costs.* Adjacent 12×12 windows with a 4×4 output overlap nine-fold, so a network
|
||||
evaluated per window recomputes each photosite's features nine times. A convolutional network is
|
||||
the same computation with that work shared: it is "predict the central block from its
|
||||
neighbourhood" evaluated everywhere at once.
|
||||
|
||||
### 3.2 The shape
|
||||
|
||||
```
|
||||
mosaic (H×W) ──space-to-depth 2×2──► 4 ch @ H/2 × W/2 ┐
|
||||
noise map σ(x) ─space-to-depth 2×2──► 4 ch @ H/2 × W/2 ┴► U-Net ─► 12 ch @ H/2 × W/2 ─depth-to-space─► RGB @ H×W
|
||||
(2×2 block × RGB per position)
|
||||
```
|
||||
|
||||
- **Packing.** Bayer is packed 2×2 into four channels at half resolution, so every input position
|
||||
is one whole quad and every output position is the 2×2 block of RGB it covers — the proposal's
|
||||
head, at the Bayer period. (A 4×4 packing with a 48-channel head is the same thing at a coarser
|
||||
stride and is a free parameter.)
|
||||
- **Phase unification.** Every body's pattern is cropped by a row or a column to RGGB before
|
||||
packing, and the output is un-cropped. Flips are only used for augmentation in the CFA-preserving
|
||||
form (Liu et al., "Bayer pattern unification and augmentation", 2019).
|
||||
- **Body.** A U-Net with four downsamplings and NAFNet blocks (Chen et al., 2022; MIT). The
|
||||
receptive field at the raw scale is several hundred photosites, which covers §3.1's worst case
|
||||
with room.
|
||||
- **Two sizes.** **M** (widths 32-64-128-256, ~6 M parameters, ~60 GMAC per raw megapixel —
|
||||
*estimate*) is the desktop model and the one trained first. **S** (widths 16-32-64-128, fewer
|
||||
bottleneck blocks, ~1 M parameters, ~12 GMAC/MP) is distilled from M for the tablet (§8).
|
||||
|
||||
### 3.3 Conditioning on the noise
|
||||
|
||||
The network is told how noisy each photosite is, rather than learning one model per ISO:
|
||||
|
||||
- A per-photosite standard-deviation map, `σ(x) = √(K·x + σ_r²)` from the body's gain `K` and read
|
||||
noise `σ_r` at that ISO, packed alongside the mosaic (FFDNet's arrangement, Zhang et al., 2018).
|
||||
- **This is what makes it camera-general.** A body it was never trained on only has to supply
|
||||
`K` and `σ_r`. Three sources, in order of preference: a calibration table for the body (§5); the
|
||||
DNG `NoiseProfile` tag, which Adobe's converter writes; a blind estimate from the photograph's
|
||||
own flat regions (Foi et al., 2008), which always exists.
|
||||
- **It is also the Amount control.** Scaling the map up tells the network there is more noise than
|
||||
there is and it smooths harder; scaling it down preserves more grain. Changing the amount re-runs
|
||||
inference (§7.2), which is why it is set on a preview crop, as Lightroom does.
|
||||
|
||||
The alternative — PMRID's k-sigma transform, which maps every ISO onto one noise level — is
|
||||
simpler and gives no Amount control. It is the fallback if conditioning underperforms.
|
||||
|
||||
### 3.4 Tiling
|
||||
|
||||
A 20 MP frame does not go through a network in one piece on either device. Inference tiles the
|
||||
mosaic into 512×512 input tiles with a 64-photosite halo on every side and keeps the central
|
||||
384×384 of each output: the proposal's "12 in, 4 out", scaled up. Halo and tile sizes must be
|
||||
multiples of 2 (the CFA phase) and of 16 (four downsamplings at half resolution), so the seams
|
||||
land at identical positions in every tile's own coordinates.
|
||||
|
||||
This is inference-local tiling and does not depend on FR-DSP-2's render-path tiling, which stays
|
||||
under the challenge [outstanding.md §4](outstanding.md) records.
|
||||
|
||||
## 4. Training data
|
||||
|
||||
### 4.1 What the library holds
|
||||
|
||||
From the reference catalog, 2026-09-27: 17,255 catalogued RAWs (9,345 DNG, 7,910 CR2), **all but
|
||||
seven from one body, the Canon EOS 6D** (RGGB Bayer, 5472×3648, AA filter), 166 shooting days from
|
||||
2015 to 2026.
|
||||
|
||||
| ISO | Frames | Use |
|
||||
|---|---|---|
|
||||
| ≤ 200 | 5,065 | Clean sources for synthetic pairs |
|
||||
| 201–1600 | 7,807 | Low-noise end of the eval set |
|
||||
| 1601–6400 | 3,379 | Real-noise eval set; noise-model check (§5.3) |
|
||||
| > 6400 | 562 | The hard cases, by eye |
|
||||
|
||||
There are **no X-Trans raws**, which matters for §9. The catalog does not hold shutter speed, so
|
||||
selection needs the files' EXIF. Whether the DNGs are mosaic (converted CR2) or linear must be
|
||||
checked before they are counted as sources: a linear DNG has no photosites to learn from.
|
||||
|
||||
### 4.2 How a training pair is made
|
||||
|
||||
1. **Clean source.** A base-ISO 6D frame, black-subtracted and normalised.
|
||||
2. **Full-colour truth by binning.** Each plane is resampled by half a photosite so the four
|
||||
planes share a centre, then every 2×2 quad becomes one RGB pixel (R, mean of the two G, B):
|
||||
a true full-colour image at 2736×1824 with no interpolation in it. This is the only way to have
|
||||
ground truth for the demosaic half.
|
||||
3. **Re-mosaic.** That RGB image is sampled back into an RGGB mosaic. (It can equally be sampled
|
||||
into X-Trans, §9.)
|
||||
4. **Darken and add noise.** Scale the signal by `1/g` for a target ISO `100·g`, then add noise
|
||||
from the calibrated model at that ISO (§5): Poisson shot, Tukey-lambda read noise, row noise
|
||||
and quantisation — the ELD model (Wei et al., CVPR 2020). The input is this mosaic; the target
|
||||
is the clean RGB at the same scale.
|
||||
5. **Augment.** Random blur (Gaussian, σ 0–0.7 px) before re-mosaicking, because a binned image is
|
||||
sharper per pixel than the AA-filtered sensor the model will see; exposure jitter; white-balance
|
||||
gains within the body's range; CFA-preserving flips.
|
||||
|
||||
**Why the target's own noise is tolerable.** A base-ISO frame is not noise-free, and binning only
|
||||
halves the green noise; red and blue keep theirs. But darkening by `g` scales signal and target
|
||||
noise together, while the added shot noise grows as `√g`. At ISO 3200 the input is ≈ 5.7× noisier
|
||||
than its target, at ISO 800 only ≈ 2.8×. L1 against a noisy target converges on the median, which
|
||||
is unbiased for symmetric noise. The low-ISO end is the one at risk of learning to keep grain: if
|
||||
it does, bin 4×4 instead (red and blue noise halved, 1368×912 per source) for those samples.
|
||||
|
||||
**Why not the native mosaic as the target.** That trains denoise alone, with base-ISO noise baked
|
||||
into the answer ("noisier2noise") and no demosaic truth at all.
|
||||
|
||||
### 4.3 How much
|
||||
|
||||
The limit is scene diversity, not pixel count; every source yields an effectively unlimited
|
||||
number of pairs through random crops, ISO and noise draws.
|
||||
|
||||
| Figure | Value | Reasoning |
|
||||
|---|---|---|
|
||||
| Sources, train | **3,000** | 5,065 base-ISO frames, less bursts (perceptual-hash dedup), heavy clipping, motion blur and linear DNGs. For scale: ELD reaches state of the art trained on ~230 scenes; SID has ~5,000 pairs of ~400 scenes |
|
||||
| Sources, validation | 200 | Split by shooting day, not by frame, so no scene is on both sides |
|
||||
| Pixels | ~15 Gpx of RGB truth | 3,000 × 5 MP after binning |
|
||||
| Crops per step | 8–16 × 256×256 photosites | Fits a 6 GB RTX 3050 at fp16 with M |
|
||||
| Stored | ~20 GB | 24 random 512×512 crops per source, uint16, zstd. Keeping whole CR2s would be ~75 GB |
|
||||
| Training | 200–400 k steps, one to two nights per run on the 3050 — *estimate*; expect three to five runs | |
|
||||
|
||||
Stratify the selection: across all 166 days, and deliberately include faces and hair (the library
|
||||
has 19k detected faces, and skin is where over-smoothing shows first), foliage, fabric, text, and
|
||||
any base-ISO tripod night work.
|
||||
|
||||
### 4.4 Reading raws the same way in training and in the app
|
||||
|
||||
The training data must be decoded by **the same decoder the app uses**. rawpy (LibRaw) and
|
||||
`dr_decode::Rawler` can disagree on black level, white level, active area and therefore CFA phase,
|
||||
and a network trained on one pattern phase and run on another produces colour moiré everywhere.
|
||||
A `dr-decode` example that dumps the mosaic and its metadata as `.npy` is the only source the
|
||||
training repo reads — not rawpy, as `darkroom-infill`'s `develop-raws.py` does.
|
||||
|
||||
## 5. The noise model and its calibration
|
||||
|
||||
### 5.1 What is measured
|
||||
|
||||
Per ISO: gain `K` (DN per electron), read-noise distribution (Gaussian σ and Tukey-λ shape),
|
||||
row-noise σ, black-level offset and any fixed pattern. Canon's third-stop ISOs on bodies of the
|
||||
6D's generation are digital gains of the full stops, so noise does not scale smoothly between
|
||||
them; **every third stop is calibrated**, not interpolated.
|
||||
|
||||
### 5.2 The capture (one hour, once per body)
|
||||
|
||||
- **Darks.** Lens cap on, viewfinder covered, manual. Five frames at 1/4000 s and five at 1/30 s
|
||||
at every third stop from ISO 100 to 25600. They give read noise, row noise and the black-level
|
||||
pattern; the two shutter speeds confirm dark current is negligible.
|
||||
- **Flats.** An evenly lit white wall, defocused, at every full stop: pairs at six exposure levels
|
||||
from 1/64 of clip to 3/4 of it. The variance of each pair's difference against their mean is
|
||||
the photon transfer curve, whose slope is `K`.
|
||||
|
||||
### 5.3 The check
|
||||
|
||||
Fit the same `(K, σ_r)` blindly from flat regions of the library's 3,379 ISO 1601–6400 frames
|
||||
(§3.3's third source). If it disagrees with the calibration by more than ~10%, one of them is
|
||||
wrong — and it tells us how far the blind estimate can be trusted for bodies with no calibration.
|
||||
|
||||
## 6. Evaluation
|
||||
|
||||
### 6.1 Real pairs (the test set)
|
||||
|
||||
Synthetic validation says whether the model learned the synthetic problem; only photographed pairs
|
||||
say whether it learned the real one. On a tripod, with remote release and mirror lock-up, manual
|
||||
focus and white balance: **12 scenes** — low-light interior, a night street, fabric, foliage, fine
|
||||
text, a colour chart if one is to hand, and a still subject with skin and hair. At each, four
|
||||
ISO 100 frames at a long exposure (averaged: the reference), then ISO 1600, 3200, 6400, 12800 and
|
||||
25600 at the same aperture with the shutter shortened by the ISO ratio. A per-channel linear fit
|
||||
against the reference absorbs residual exposure mismatch (ELD's protocol).
|
||||
|
||||
Plus 100 real library frames above ISO 3200 with no reference, judged by eye side by side.
|
||||
|
||||
### 6.2 Baseline and metrics
|
||||
|
||||
The baseline is today's path: the classical demosaic plus `ops/noise_reduction.rs` tuned by hand
|
||||
per ISO on the validation set. If a Lightroom or DxO trial is to hand, their output on the same
|
||||
twelve scenes is the ceiling, for our comparison only.
|
||||
|
||||
Metrics, measured after a fixed tone curve (the camera profile and an sRGB curve) and not in linear
|
||||
light, where the highlights would dominate: PSNR and SSIM per ISO; chroma bias on flat patches,
|
||||
because denoisers desaturate; a slanted-edge MTF for detail; and maze or zipper artefacts on the
|
||||
resolution target at ISO 100.
|
||||
|
||||
### 6.3 Experiments that answer design questions
|
||||
|
||||
| | Question | Runs |
|
||||
|---|---|---|
|
||||
| E1 | How much context does denoise need? (§3.1) | Same data, receptive field 12, 36, 100, 300+ photosites; PSNR per ISO against it |
|
||||
| E2 | Noise-map conditioning or k-sigma? (§3.3) | M both ways |
|
||||
| E3 | Bin 2×2 or 4×4 for truth? (§4.2) | Compare at ISO 400–800, where it matters |
|
||||
| E4 | Is the blind noise estimate good enough? (§5.3) | Inference with calibrated vs blind maps on the real pairs |
|
||||
|
||||
### 6.4 Acceptance
|
||||
|
||||
- On the real pairs, ≥ 3 dB over the baseline at ISO 6400, and **no ISO at which it is worse**,
|
||||
ISO 100 included — at base ISO it has to be at least as good a demosaic as the classical one.
|
||||
- Mean chroma error on flat patches under ΔE 1.
|
||||
- No maze, zipper or false colour on the resolution target that the classical demosaic does not
|
||||
also show.
|
||||
- A 20 MP frame in ≤ 3 s on the laptop's GPU and ≤ 30 s on its CPU (§8).
|
||||
|
||||
## 7. In the application
|
||||
|
||||
### 7.1 A cache, not a new file
|
||||
|
||||
Lightroom writes a DNG into the library. We do not: the library is synced, a 20 MP linear RGB file
|
||||
is ~120 MB, and a derived file inside a synced tree is exactly what
|
||||
[storage.md](storage.md) refuses. Instead:
|
||||
|
||||
- The sidecar records the intent — denoise on, amount, model id — as the rest of the edit is
|
||||
recorded, so it syncs and another device reproduces it.
|
||||
- The result is a local cache entry: f16 linear camera RGB, zstd, keyed on
|
||||
`(file identity, decoder version, model id, amount, noise source)`. ~60–80 MB per frame
|
||||
(*estimate*), LRU under a budget (default 5 GB, §10).
|
||||
- On open, the classical demosaic shows at once and the learned result swaps in when it is ready,
|
||||
with progress over the canvas — the same pattern as a photograph that is only on the server.
|
||||
- Export needs the result and computes it if the cache has lost it.
|
||||
|
||||
### 7.2 The Amount control
|
||||
|
||||
A Denoise toggle and one Amount slider in develop. Moving the slider runs inference on the
|
||||
**visible viewport only** (~1 MP, a fraction of a second — *estimate*) so the photographer judges
|
||||
on the real result; releasing it queues the whole frame. There is no per-frame blend between the
|
||||
two paths: blending the classical output back in re-adds the noise the network removed.
|
||||
|
||||
### 7.3 Runtime
|
||||
|
||||
Through `dr-inference-engine`, as the other models run ([inference.md](inference.md)): TensorRT or
|
||||
CUDA fp16 on the laptop, MIGraphX on the desktop, ORT CPU everywhere, QNN on the tablet. Work is
|
||||
scheduled in the `Background` class so a slider never waits on it (architecture §5.3).
|
||||
|
||||
## 8. Speed and the tablet
|
||||
|
||||
M at ~60 GMAC/MP is ~1.2 TMAC for a 20 MP frame (*estimate*). On the RTX 3050 at fp16 that is
|
||||
about a second; on 20 CPU threads, tens of seconds.
|
||||
|
||||
The tablet's Hexagon is fast — scrfd_10g's ~10 GFLOP in 3.2 ms, [inference.md §1.1](inference.md) —
|
||||
but **accepts int8 only**, and int8 is hostile to this task: a 14-bit signal quantised to 256
|
||||
levels loses the shadow steps the model exists to recover. Two ways round it, to be measured in
|
||||
this order:
|
||||
|
||||
1. **Predict the residual, not the image.** S emits the correction to a cheap bilinear demosaic
|
||||
computed in float outside the graph. The residual spans a few σ, which 256 levels resolve; the
|
||||
addition happens in float. With a variance-stabilising transform (Anscombe) on the input.
|
||||
2. **16-bit activations** (QNN's A16W8), if the partition log shows the HTP running them.
|
||||
|
||||
If neither holds S's quality within 0.5 dB of fp32 on the real pairs, **v1 is desktop-only** and the
|
||||
tablet shows the classical path. The sidecar still records the intent, so a desktop can render the
|
||||
learned result for a photograph edited on the tablet.
|
||||
|
||||
## 9. X-Trans
|
||||
|
||||
The requirements tie this stage to FR-RAW-5, and the library has no Fuji raws. What we can do
|
||||
without a Fuji body:
|
||||
|
||||
- **Training does not need one.** §4.2 step 3 samples the binned RGB truth into any pattern.
|
||||
X-Trans packs 6×6 into 36 channels at a sixth of the resolution, with a 108-channel head: the
|
||||
same design at the X-Trans period. It is a separate model.
|
||||
- **Noise does.** A calibration capture (§5.2) or, failing that, the blind estimate — plus the
|
||||
DNG `NoiseProfile` of converted Fuji files.
|
||||
- **The test set does.** raw.pixls.us has CC0 samples per body but no tripod ISO ladders. A few
|
||||
hours with a borrowed X-Trans body and the §6.1 protocol is the honest version; without it,
|
||||
X-Trans ships marked experimental.
|
||||
|
||||
## 10. Plan and open decisions
|
||||
|
||||
| Phase | Work | Output |
|
||||
|---|---|---|
|
||||
| P0 | Calibration capture; the `dr-decode` dump example; source selection and crop store | Noise tables, ~20 GB of crops, the 12-scene test set |
|
||||
| P1 | M on Bayer; eval harness; E1–E4 | A model that passes §6.4 on the laptop |
|
||||
| P2 | The stage in `dr-gpu`, cache, sidecar field, develop controls, export | A photograph denoised in the app |
|
||||
| P3 | S distilled; int8 and the residual head on the tablet | Tablet in or out of v1 (§8) |
|
||||
| P4 | X-Trans model | Experimental unless a body is borrowed |
|
||||
|
||||
Training lives in a sibling repo, `darkroom-denoise`, next to `darkroom-infill` and reusing its
|
||||
hydration tools. The weights are trained from scratch on the author's own photographs with an
|
||||
MIT architecture, so this model adds no third-party licence to D13.
|
||||
|
||||
**Decisions wanted before P1:**
|
||||
|
||||
1. Bin 2×2 or 4×4 for the truth, or both (E3 answers it, but the crop store is built once).
|
||||
2. Cache budget and location.
|
||||
3. Whether the tablet is in v1's scope or explicitly deferred behind §8's measurement.
|
||||
4. Whether a Lightroom or DxO comparison is available for §6.2.
|
||||
5. A borrowed X-Trans body, or X-Trans experimental in v1.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
+82
-4
@@ -294,6 +294,53 @@ carrying the first source's EXIF in a sub-IFD as `dr-export` already does.
|
||||
file is written and catalogued, beside its sources, with the merge as the
|
||||
first entry in its history.
|
||||
|
||||
**How it gets there (FR-MRG-6, 2026-09-28).** A rescan fired as the merge
|
||||
finished raced the upload it followed — the 800 MB copy into a folder library
|
||||
was still running when the folder was listed, and a Nextcloud upload takes
|
||||
minutes — so the listing lacked the composite, recorded the folder's
|
||||
validator, and the grid did not show it until the next sync pass. Now:
|
||||
|
||||
- *Catalogued by the merge.* `MergeEvent::Done` carries a `Composite` — the
|
||||
name it will have, the size of the picture it opens on (the crop, or the
|
||||
whole when filled), the capture time written into the DNG (the mean of the
|
||||
frames'; the sources' earliest where none has one), the body, and its
|
||||
thumbnails. `library::catalogue_composite` writes the row in one
|
||||
transaction, keyed on `(root_id, source_ref)` exactly as the scan will list
|
||||
the file, at `metadata_state = 2`, and the grid reloads. The name is chosen
|
||||
against the catalog's names in that folder (`names_in_folder`), since the
|
||||
upload replaces whatever is at its name.
|
||||
- *The server's half after the upload.* Once a file the catalog already has
|
||||
a row for is sent, the drain lists its folder once, records the file id the
|
||||
server assigned (`record_uploaded`) and puts the merge's thumbnails in the
|
||||
store under it; then the grid rescans. A scan that ran before the upload
|
||||
leaves the row alone, and the one after it updates it in place.
|
||||
- *Thumbnails from the merge.* The bands are box-reduced as they are written,
|
||||
after the fill, to a copy 4096 pixels long (`merge_thumbs::Reduced`). That
|
||||
copy is written as a linear DNG in memory with the composite's own profile,
|
||||
header and crop and opened through `open_session` — develop's first open:
|
||||
the D19 pipeline, the default view transform and tone mapping, the as-shot
|
||||
balance and the working-space-to-display conversion. The grid, large and
|
||||
wide classes are rendered from that session, staged in the outbox as
|
||||
`x.dng.thumbs` before the rename releases the payload, and drawn from
|
||||
memory until the upload has a file id to store them under. A test develops
|
||||
a synthetic composite both ways and holds the mean, 95th and 99.5th luma
|
||||
percentiles within 3–4 levels; the naive balanced-and-gamma picture misses
|
||||
by 13. Older composites, which have no staged thumbnails, are thumbnailed
|
||||
the ordinary way.
|
||||
- *A wide cell.* `library_ui::layout` places the grid as a lattice of slots.
|
||||
`natural_span` maps aspect to 2, 3 or 4 columns (from 1.9, 2.45 and 3.46 —
|
||||
√(s(s+1)) is where two neighbouring classes leave the same share of their
|
||||
cell empty), capped at the columns there are and the whole row on the
|
||||
tablet, and the same number names the thumbnail class (`Wide2`–`Wide4`, 512
|
||||
pixels of long edge per column). A wide cell that does not fit in the rest
|
||||
of a row starts the next; nothing later moves into the gap, so ordinals —
|
||||
the arrows, a shift-click's run, the timeline, burst folding — are
|
||||
untouched, and up/down step by rows through the layout. The window's own
|
||||
read carries `w` and `h`; where the wide ones sit in the whole list is one
|
||||
query, run when the list changes, and a library with no panorama answers it
|
||||
from the partial index `images_wide`, created on first use rather than by a
|
||||
schema bump.
|
||||
|
||||
## 10. Order of work
|
||||
|
||||
1. **S15**, all four, before anything else. (1) and (2) are a day each and
|
||||
@@ -320,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 |
|
||||
| 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 |
|
||||
| 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 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 |
|
||||
@@ -338,9 +385,11 @@ half.
|
||||
the file carries the black border. The largest inscribed rectangle over
|
||||
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
|
||||
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
|
||||
small misalignment; parallax on the near slope will show as a soft
|
||||
double edge at 1:1.
|
||||
2. **The pyramid** (§10 step 5). Seams landed 2026-09-30 (§11.1); the
|
||||
blend across them is one width for every frequency, so an exposure step
|
||||
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
|
||||
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
|
||||
@@ -355,6 +404,35 @@ half.
|
||||
catalog's `content_hash` is null for most images most of the time. The
|
||||
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
|
||||
|
||||
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 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
|
||||
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
|
||||
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
|
||||
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
|
||||
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
|
||||
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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
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**
|
||||
|
||||
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
||||
|
||||
+109
-109
File diff suppressed because one or more lines are too long
+50
-50
@@ -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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1977`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2029`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2073`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2125`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2164`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2216`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2390`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2442`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2420`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2472`</sub>
|
||||
|
||||
### 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`
|
||||
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2434`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
|
||||
|
||||
### 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
|
||||
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2454`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2506`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2473`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2538`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2504`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2556`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2529`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2581`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2542`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2594`</sub>
|
||||
|
||||
### 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
|
||||
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2607`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2659`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2624`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2676`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2653`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2705`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2681`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2733`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2717`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2769`</sub>
|
||||
|
||||
### 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
|
||||
- **Keyboard** — `0`–`5`
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2774`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2826`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2780`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2832`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2810`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2862`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2835`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2887`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2965`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:3012`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1024`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:1026`</sub>
|
||||
|
||||
### 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
|
||||
- **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
|
||||
|
||||
@@ -521,7 +521,7 @@ The right match confidence is a property of your library, not of the model. "Wha
|
||||
|
||||
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1706`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1732`</sub>
|
||||
|
||||
### Add or remove one photograph
|
||||
|
||||
@@ -531,7 +531,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
|
||||
|
||||
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1716`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1742`</sub>
|
||||
|
||||
### Leave selecting
|
||||
|
||||
@@ -540,7 +540,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
||||
- **Keyboard** — `Escape`, or `Back`; an open sheet closes first
|
||||
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1725`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1751`</sub>
|
||||
|
||||
### Pick a photograph up to drag it
|
||||
|
||||
@@ -550,7 +550,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
||||
|
||||
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1756`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1782`</sub>
|
||||
|
||||
### Select a range
|
||||
|
||||
@@ -561,7 +561,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
|
||||
|
||||
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1822`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1848`</sub>
|
||||
|
||||
### Take the blinks out of a burst
|
||||
|
||||
@@ -571,7 +571,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
||||
|
||||
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2540`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2573`</sub>
|
||||
|
||||
### Find photographs with two people in them
|
||||
|
||||
@@ -581,7 +581,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
|
||||
|
||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2570`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2603`</sub>
|
||||
|
||||
### Show only photographs with one colour label
|
||||
|
||||
@@ -591,7 +591,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
|
||||
|
||||
Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2694`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2727`</sub>
|
||||
|
||||
### Export the selection as the last export was
|
||||
|
||||
@@ -602,7 +602,7 @@ Each chip is the label's mark and its name, so the one you want is found by read
|
||||
|
||||
Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3164`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3197`</sub>
|
||||
|
||||
### Paste copied settings onto the selection
|
||||
|
||||
@@ -611,7 +611,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
|
||||
- **Keyboard** — `Ctrl+V`
|
||||
- **See it** — [in the manual](manual/README.md#copying-settings)
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3188`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3221`</sub>
|
||||
|
||||
### Keyword the selection
|
||||
|
||||
@@ -621,7 +621,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
|
||||
|
||||
Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3217`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3250`</sub>
|
||||
|
||||
### Show only photographs with some number of stars
|
||||
|
||||
@@ -632,7 +632,7 @@ Lightroom's keywording chord. The sheet opens with its field ready for typing, s
|
||||
|
||||
The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3251`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3284`</sub>
|
||||
|
||||
### Give photographs a colour label
|
||||
|
||||
@@ -643,7 +643,7 @@ The chips say "this many or more". A range with a ceiling — the twos and three
|
||||
|
||||
Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3301`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3334`</sub>
|
||||
|
||||
### Pick or reject a photograph
|
||||
|
||||
@@ -653,7 +653,7 @@ Lightroom's keys, so hands that learned them there need not learn them again. Pu
|
||||
|
||||
The keys every culling tool uses, so muscle memory built elsewhere works here.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3325`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3358`</sub>
|
||||
|
||||
### Move photographs to the trash
|
||||
|
||||
@@ -663,7 +663,7 @@ The keys every culling tool uses, so muscle memory built elsewhere works here.
|
||||
|
||||
The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3352`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3385`</sub>
|
||||
|
||||
### Open this list
|
||||
|
||||
@@ -671,7 +671,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
|
||||
- **Pointer** — Press Help in the header, and Done to put it away
|
||||
- **Keyboard** — `F1`, and `Escape` to put it away
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3377`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3410`</sub>
|
||||
|
||||
### Rename the collection the grid is showing
|
||||
|
||||
@@ -679,7 +679,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
|
||||
- **Pointer** — Double-click it in the sidebar
|
||||
- **Keyboard** — `F2`
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3385`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3418`</sub>
|
||||
|
||||
### Move through the grid
|
||||
|
||||
@@ -689,7 +689,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
|
||||
|
||||
The cursor selects what it lands on, so walking and judging are one hand's work.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3405`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3438`</sub>
|
||||
|
||||
### Resize the thumbnails
|
||||
|
||||
@@ -700,7 +700,7 @@ The cursor selects what it lands on, so walking and judging are one hand's work.
|
||||
|
||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3536`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3567`</sub>
|
||||
|
||||
### File photographs in a collection
|
||||
|
||||
@@ -710,7 +710,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
|
||||
|
||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3735`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3766`</sub>
|
||||
|
||||
### Open a photograph
|
||||
|
||||
@@ -721,7 +721,7 @@ The selection is what the drag carries, which is why selecting several is worth
|
||||
|
||||
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4040`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4073`</sub>
|
||||
|
||||
### Rate a photograph without opening it
|
||||
|
||||
@@ -732,7 +732,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
|
||||
|
||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4163`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4196`</sub>
|
||||
|
||||
### Choose the frame a folded burst shows
|
||||
|
||||
@@ -742,7 +742,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
||||
|
||||
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4296`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4329`</sub>
|
||||
|
||||
### Drop the selection but keep selecting
|
||||
|
||||
@@ -753,7 +753,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
|
||||
|
||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4987`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:5022`</sub>
|
||||
|
||||
### Select everything the grid is showing
|
||||
|
||||
@@ -764,7 +764,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
||||
|
||||
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:5006`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:5041`</sub>
|
||||
|
||||
### Take photographs out of a collection
|
||||
|
||||
@@ -774,7 +774,7 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
|
||||
|
||||
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:5187`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:5222`</sub>
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
+17
-2
@@ -75,6 +75,14 @@ past the window. On a tablet they scroll by flick alone, and a thin line at
|
||||
the right-hand edge shows where the view is while it moves, fading once it
|
||||
stops; it is only a picture, and a flick that starts on it scrolls the list.
|
||||
|
||||
A panorama gets a wider cell: about twice as wide as it is tall and it spans
|
||||
two columns, then three, then four for the widest — whichever leaves the least
|
||||
of the cell empty — with a thumbnail made for that width. One that would not
|
||||
fit in what is left of a row starts the next, so the grid still reads in the
|
||||
order the photographs were taken; the arrows walk it in that order, and up and
|
||||
down go to whatever is above or below. On a tablet, or with too few columns to
|
||||
put it beside anything, a panorama takes the whole row.
|
||||
|
||||
`Help` in the header, or `F1`, opens the controls and shortcuts: every key and
|
||||
gesture, screen by screen, with `See it` beside those this page shows, and
|
||||
`Manual` to open this page. In develop it is the `?` beside `Settings`.
|
||||
@@ -345,8 +353,13 @@ outlined where it landed — twelve hand-held portrait frames across an alpine
|
||||
valley, here. Change the projection (a 150° sweep on a flat perspective is
|
||||
what the middle of the film shows, and why cylindrical is suggested), ask for
|
||||
the border to be filled rather than cropped, then `Merge`. The composite is
|
||||
written beside its sources as a DNG and appears in the grid with the merge
|
||||
as the first step in its history.
|
||||
written beside its sources as a DNG and is in the grid the moment it is
|
||||
written — in a wide cell beside its frames, placed by when they were taken —
|
||||
with the merge as the first step in its history. Its thumbnail is made during
|
||||
the merge, from the finished picture, as develop will show it when you open
|
||||
it; on a server library it is in the grid while the file is still uploading.
|
||||
A second merge of the same frames is named `-pano-2`, never written over the
|
||||
first.
|
||||
|
||||
Each frame has a box in the `Frames` list. Untick one to leave it out, and
|
||||
the rest are aligned again at once, without reading the frames again; tick
|
||||
@@ -367,6 +380,8 @@ goes for a panorama Lightroom stitched and saved as a DNG.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Export
|
||||
|
||||
`Export` in the develop header, or `Export N` from a selection. Format,
|
||||
|
||||
+15
-2
@@ -194,6 +194,13 @@ the sidebar, the develop column and Settings have one too whenever they run
|
||||
past the window. On a tablet they scroll by flick alone, and a thin line at
|
||||
the right-hand edge shows where the view is while it moves, fading once it
|
||||
stops; it is only a picture, and a flick that starts on it scrolls the list.</p>
|
||||
<p>A panorama gets a wider cell: about twice as wide as it is tall and it spans
|
||||
two columns, then three, then four for the widest — whichever leaves the least
|
||||
of the cell empty — with a thumbnail made for that width. One that would not
|
||||
fit in what is left of a row starts the next, so the grid still reads in the
|
||||
order the photographs were taken; the arrows walk it in that order, and up and
|
||||
down go to whatever is above or below. On a tablet, or with too few columns to
|
||||
put it beside anything, a panorama takes the whole row.</p>
|
||||
<p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and
|
||||
gesture, screen by screen, with <code>See it</code> beside those this page shows, and
|
||||
<code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p>
|
||||
@@ -389,8 +396,13 @@ outlined where it landed — twelve hand-held portrait frames across an alpine
|
||||
valley, here. Change the projection (a 150° sweep on a flat perspective is
|
||||
what the middle of the film shows, and why cylindrical is suggested), ask for
|
||||
the border to be filled rather than cropped, then <code>Merge</code>. The composite is
|
||||
written beside its sources as a DNG and appears in the grid with the merge
|
||||
as the first step in its history.</p>
|
||||
written beside its sources as a DNG and is in the grid the moment it is
|
||||
written — in a wide cell beside its frames, placed by when they were taken —
|
||||
with the merge as the first step in its history. Its thumbnail is made during
|
||||
the merge, from the finished picture, as develop will show it when you open
|
||||
it; on a server library it is in the grid while the file is still uploading.
|
||||
A second merge of the same frames is named <code>-pano-2</code>, never written over the
|
||||
first.</p>
|
||||
<p>Each frame has a box in the <code>Frames</code> list. Untick one to leave it out, and
|
||||
the rest are aligned again at once, without reading the frames again; tick
|
||||
it to bring it back. A frame that cannot be placed is named there with why,
|
||||
@@ -404,6 +416,7 @@ goes for a panorama Lightroom stitched and saved as a DNG.</p>
|
||||
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled</figcaption></figure>
|
||||
<figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure>
|
||||
<figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure>
|
||||
<figure><img loading="lazy" src="media/panorama-in-grid.png" alt="The composite in the grid straight after the merge, spanning four columns beside the twelve frames it was made from"><figcaption>The composite in the grid straight after the merge, spanning four columns beside the twelve frames it was made from</figcaption></figure>
|
||||
<h2 id="export">Export</h2>
|
||||
<p><code>Export</code> in the develop header, or <code>Export N</code> from a selection. Format,
|
||||
size, colour space, sharpening and naming are in Settings, and apply to every
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -4,7 +4,7 @@
|
||||
# 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.
|
||||
pkgname=darkroom
|
||||
pkgver=0.19.0
|
||||
pkgver=0.20.0
|
||||
# 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.
|
||||
pkgrel=1
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 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
|
||||
//! 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,
|
||||
//! 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
|
||||
/// 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 can change:
|
||||
/// It is `true` on Linux and on Android. On Android the card is read by
|
||||
/// 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
|
||||
/// through a tree the user granted, and a removable volume appears there or
|
||||
/// not at all (ARCH §6.9).
|
||||
/// - Nothing implements [`WritableStorage`](crate::WritableStorage) except
|
||||
/// [`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.
|
||||
/// The engine above this takes storage traits and never a path, and the
|
||||
/// importer only ever *writes* into its own staging directory, which is a
|
||||
/// plain directory on every platform. So what this gates is whether a card
|
||||
/// can be *read*, nothing more.
|
||||
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.
|
||||
@@ -130,9 +126,9 @@ fn platform_volumes() -> Vec<Volume> {
|
||||
|
||||
/// Everywhere else: no answer, and saying so is the honest result.
|
||||
///
|
||||
/// On Android the question is not merely unanswerable but wrong — storage is
|
||||
/// reached through a tree the user granted, and a card appears there or not at
|
||||
/// all (ARCH §6.9, FR-PLAT-AND-1).
|
||||
/// On Android there is no readable mount table either; the volumes come from
|
||||
/// `StorageManager` through `dr_ui::cards`, which needs the JNI this crate
|
||||
/// does not have.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn platform_volumes() -> Vec<Volume> {
|
||||
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",
|
||||
// `false` here means "this cannot be done here". An interface that
|
||||
// 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() {
|
||||
assert!(volumes().is_empty());
|
||||
}
|
||||
|
||||
+11
-2
@@ -1314,9 +1314,10 @@ def join(name, parts):
|
||||
os.remove(f'{OUT}/{p}.mp4')
|
||||
|
||||
|
||||
@scene(media=['panorama.gif', 'panorama-aligned.png', 'panorama-filled.png'],
|
||||
@scene(media=['panorama.gif', 'panorama-aligned.png', 'panorama-filled.png', 'panorama-in-grid.png'],
|
||||
sources=COMMON + ['ui/dr-ui/ui/merge.slint', 'ui/dr-ui/src/merge_ui.rs', 'ui/dr-ui/src/merge.rs',
|
||||
'core/dr-pano/src/**'])
|
||||
'ui/dr-ui/src/merge_thumbs.rs', 'ui/dr-ui/src/library_ui/layout.rs',
|
||||
'ui/dr-ui/ui/library.slint', 'core/dr-pano/src/**'])
|
||||
def panorama():
|
||||
select_pano()
|
||||
rec('panorama-0')
|
||||
@@ -1358,6 +1359,14 @@ def panorama():
|
||||
wait_for_new(DEMO_LIBRARY, '.dng', started, 600) if DEMO_LIBRARY else pause(60)
|
||||
pause(5)
|
||||
at_library()
|
||||
# The composite in the grid beside its frames, in its wide cell, with the
|
||||
# thumbnail the merge made: catalogued when it was written, not when a
|
||||
# scan got round to it.
|
||||
grid_top()
|
||||
dr.wait_for(f'{PANO_FIRST}-pano', 120)
|
||||
show_cell(f'{PANO_FIRST}-pano')
|
||||
pause(2)
|
||||
shot('panorama-in-grid')
|
||||
|
||||
|
||||
# --- settings ---------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! 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
|
||||
//! page's box would, so the job aligns again without it.
|
||||
//!
|
||||
@@ -25,6 +28,7 @@ fn main() {
|
||||
let mut fill = false;
|
||||
let mut wait_engines = false;
|
||||
let mut leave_out: Option<usize> = None;
|
||||
let mut seams = true;
|
||||
loop {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--out") => {
|
||||
@@ -39,6 +43,10 @@ fn main() {
|
||||
args.remove(0);
|
||||
leave_out = args.remove(0).parse().ok();
|
||||
}
|
||||
Some("--feather-only") => {
|
||||
args.remove(0);
|
||||
seams = false;
|
||||
}
|
||||
Some("--wait-engines") => {
|
||||
args.remove(0);
|
||||
wait_engines = true;
|
||||
@@ -101,6 +109,7 @@ fn main() {
|
||||
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||
// The filler from the shared models directory, as the app finds it.
|
||||
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
||||
request.seams = seams;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (decide, decision) = std::sync::mpsc::channel();
|
||||
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.
|
||||
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,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: "Stubco".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×.
|
||||
///
|
||||
/// 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 {
|
||||
let v = self.graph.framing().view();
|
||||
if v.width <= 0.0 {
|
||||
let shorter = v.width.min(v.height);
|
||||
if shorter <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
1.0 / v.width
|
||||
1.0 / shorter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,16 +549,22 @@ impl DevelopSession {
|
||||
/// magnifying the photograph rather than sliding it around.
|
||||
///
|
||||
/// `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;
|
||||
|
||||
let view = self.graph.framing().view();
|
||||
let current = if view.width > 0.0 {
|
||||
1.0 / view.width
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||||
let target = (self.zoom() * factor).clamp(1.0, MAX_ZOOM);
|
||||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||||
// stopping a fraction short and leaving the image imperceptibly
|
||||
// panned.
|
||||
@@ -562,7 +574,7 @@ impl DevelopSession {
|
||||
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
|
||||
// under the cursor afterwards.
|
||||
@@ -570,19 +582,53 @@ impl DevelopSession {
|
||||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||||
|
||||
self.set_view_clamped(
|
||||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||||
extent,
|
||||
anchor_x - at_x.clamp(0.0, 1.0) * ew,
|
||||
anchor_y - at_y.clamp(0.0, 1.0) * eh,
|
||||
(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.
|
||||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||||
let view = self.graph.framing().view();
|
||||
self.set_view_clamped(
|
||||
view.x + dx * view.width,
|
||||
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
|
||||
/// one was left under.
|
||||
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
|
||||
let extent =
|
||||
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
|
||||
let zoom = self.one_to_one_zoom(viewport_w, viewport_h);
|
||||
let (ew, eh) = self.view_extents(zoom, viewport_w, viewport_h);
|
||||
self.set_view_clamped(x - ew / 2.0, y - eh / 2.0, (ew, eh));
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4 | FR-DEV-3
|
||||
@@ -689,19 +735,19 @@ impl DevelopSession {
|
||||
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
|
||||
/// boundary would show undefined area beside the photograph, which reads
|
||||
/// 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) {
|
||||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let max = 1.0 - extent;
|
||||
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: (f32, f32)) {
|
||||
let ew = extent.0.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let eh = extent.1.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
self.graph.framing_mut().set_view(CropRect {
|
||||
x: x.clamp(0.0, max.max(0.0)),
|
||||
y: y.clamp(0.0, max.max(0.0)),
|
||||
width: extent,
|
||||
height: extent,
|
||||
x: x.clamp(0.0, (1.0 - ew).max(0.0)),
|
||||
y: y.clamp(0.0, (1.0 - eh).max(0.0)),
|
||||
width: ew,
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -856,7 +931,7 @@ mod tests {
|
||||
let Some(ctx) = headless() else { return };
|
||||
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_eq!(
|
||||
@@ -919,7 +994,7 @@ mod tests {
|
||||
.expect("session");
|
||||
|
||||
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");
|
||||
let zoomed = session.render(64, 64).expect("zoomed render");
|
||||
|
||||
@@ -963,12 +1038,12 @@ mod tests {
|
||||
!session.magnifies_source(200, 200),
|
||||
"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!(
|
||||
!session.magnifies_source(200, 200),
|
||||
"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!(
|
||||
session.magnifies_source(200, 200),
|
||||
"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.
|
||||
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");
|
||||
assert_eq!(
|
||||
(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
|
||||
/// The keystone sliders are edits with a history, a reset, and a crop
|
||||
/// that follows them.
|
||||
|
||||
@@ -860,7 +860,7 @@ mod tests {
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
assert!(
|
||||
@@ -878,7 +878,7 @@ mod tests {
|
||||
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();
|
||||
session.pan_by(0.3, 0.0);
|
||||
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
|
||||
/// 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
|
||||
/// rect shrinking while the target keeps its size is how a zoom short of 1:1
|
||||
/// gains detail. The two branches meet at 1:1, where both are the viewport.
|
||||
/// Below 1:1 this is [`fit`] of the *viewed region*: the view rect shrinking
|
||||
/// while the target keeps its size is how a zoom short of 1:1 gains detail.
|
||||
/// 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(
|
||||
framed: (u32, u32),
|
||||
view: (f32, f32),
|
||||
viewport: (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));
|
||||
}
|
||||
let behind = |edge: u32, fraction: f32| {
|
||||
((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 {
|
||||
@@ -1609,6 +1616,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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]),
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
|
||||
@@ -70,6 +70,12 @@ pub struct DevelopSession {
|
||||
/// satisfies, and this says whether a *measurement* was found. Only the
|
||||
/// second can honestly caption "no profile".
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
// and not found" — `lens_summary` distinguishes them.
|
||||
lens_profile_found: false,
|
||||
profile_offer: None,
|
||||
ctx: ctx.clone(),
|
||||
graph,
|
||||
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
|
||||
/// 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
|
||||
/// a correction that silently did nothing is worse than one visibly
|
||||
/// 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]
|
||||
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
@@ -232,6 +232,7 @@ mod tests {
|
||||
color_matrix: Some(cam_to_srgb),
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
@@ -305,6 +306,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
@@ -357,6 +359,7 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
|
||||
@@ -293,6 +293,11 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
||||
request.settings.target = target;
|
||||
request.settings.destination = folder;
|
||||
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();
|
||||
*cancel.borrow_mut() = token.clone();
|
||||
@@ -315,12 +320,14 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
||||
total,
|
||||
to,
|
||||
move |written| {
|
||||
drain_outbox(&library_for_drain);
|
||||
// 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()) {
|
||||
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 session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let viewport = viewport.clone();
|
||||
window.on_zoom_at(move |factor, at_x, at_y| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let (vw, vh) = *viewport.borrow();
|
||||
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);
|
||||
});
|
||||
@@ -1042,9 +1051,10 @@ fn wire_zoom_pan_crop(
|
||||
width,
|
||||
height,
|
||||
};
|
||||
// The overlay reports the corner it is *holding*; the point
|
||||
// that must not move is the opposite one. A move reports no
|
||||
// corner at all, and keeps the shape it already has — there is
|
||||
// The overlay reports the handle it is *holding*; the point
|
||||
// that must not move is the opposite one — the far corner, or
|
||||
// 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
|
||||
// an over-moved rect smaller instead of sliding it along the
|
||||
// edge.
|
||||
|
||||
+1001
-34
File diff suppressed because it is too large
Load Diff
+100
-1
@@ -68,6 +68,13 @@ pub struct ImportController {
|
||||
selected: Cell<i32>,
|
||||
/// The source. Typed or chosen; a card the app did not find is still a card.
|
||||
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.
|
||||
survey: RefCell<Option<(usize, u64)>>,
|
||||
@@ -106,6 +113,8 @@ impl ImportController {
|
||||
volumes: RefCell::new(Vec::new()),
|
||||
selected: Cell::new(-1),
|
||||
card: RefCell::new(String::new()),
|
||||
needs_access: Cell::new(false),
|
||||
access_watch: RefCell::new(None),
|
||||
survey: RefCell::new(None),
|
||||
surveying: Cell::new(false),
|
||||
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.
|
||||
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
|
||||
// because the app happened to find three other volumes.
|
||||
if self.card.borrow().is_empty() {
|
||||
@@ -160,6 +169,34 @@ impl ImportController {
|
||||
*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.
|
||||
fn ingest_options(&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)),
|
||||
);
|
||||
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_survey_summary(
|
||||
@@ -428,6 +466,20 @@ where
|
||||
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.
|
||||
@@ -567,6 +619,53 @@ fn wire_running<C, F>(
|
||||
|
||||
/// 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.
|
||||
/// 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(
|
||||
w: &AppWindow,
|
||||
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
|
||||
// is fitted onto the screen.
|
||||
"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
|
||||
// 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
|
||||
// panel under its own name — see `rows_filtered`.
|
||||
"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",
|
||||
// In stops above middle grey: where the scene reaches display white.
|
||||
"param.view_transform.white" => "White Point",
|
||||
|
||||
+171
-43
@@ -24,6 +24,7 @@ mod albums_ui;
|
||||
#[cfg(all(feature = "automation", unix))]
|
||||
mod automation;
|
||||
mod bursts;
|
||||
mod cards;
|
||||
mod collections_ui;
|
||||
#[cfg(test)]
|
||||
mod decoder_seam;
|
||||
@@ -59,6 +60,7 @@ pub mod manual;
|
||||
mod masks_ui;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod merge_thumbs;
|
||||
mod merge_ui;
|
||||
mod net_runtime;
|
||||
mod peaking;
|
||||
@@ -322,6 +324,13 @@ pub(crate) fn open_session(
|
||||
// the capture date because of this line; before it, the same photograph
|
||||
// exported from the grid kept them and exported from develop did not.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -710,6 +719,7 @@ fn batch_request(
|
||||
images: Vec::new(),
|
||||
conn: library.credentials(),
|
||||
settings: stored.export,
|
||||
remote_names: Default::default(),
|
||||
outbox: match library.session() {
|
||||
Some(c) => export::outbox_dir(&c.account),
|
||||
// No account, so no outbox — a device export still works, and a
|
||||
@@ -731,7 +741,7 @@ fn batch_request(
|
||||
/// the first is what makes an upload feel immediate, and the second is what
|
||||
/// eventually delivers the exports made while the train was in a tunnel.
|
||||
/// Running it twice over an empty outbox costs a directory listing.
|
||||
fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
|
||||
fn drain_outbox(library: &Rc<library_ui::LibraryController>, window: slint::Weak<AppWindow>) {
|
||||
// Offline is not a failure worth reporting here — the entries stay
|
||||
// staged and the next pass takes them.
|
||||
if library.is_offline() {
|
||||
@@ -747,23 +757,7 @@ fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
|
||||
|
||||
let root = conn.account.root.clone();
|
||||
let rx = export::spawn_upload(conn, root, outbox);
|
||||
executors::spawn(executors::Executor::Io, "upload-log", move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
match msg {
|
||||
export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
||||
export::UploadMessage::Finished {
|
||||
uploaded,
|
||||
remaining,
|
||||
error,
|
||||
} => {
|
||||
log::info!("export: {uploaded} uploaded, {remaining} still queued");
|
||||
if let Some(e) = error {
|
||||
log::warn!("export upload stopped: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export::watch_upload(rx, window);
|
||||
}
|
||||
|
||||
/// What the export button should say, given where an export would go.
|
||||
@@ -807,29 +801,36 @@ pub(crate) fn refresh_export_label(window: &AppWindow) {
|
||||
/// dragged, because each move event destroys the thing that would deliver
|
||||
/// the next one.
|
||||
/// 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
|
||||
/// magnitude smaller, so it has almost always landed by the time there is a
|
||||
/// session to apply it to — and this takes it straight from the channel. The
|
||||
/// timer covers the case where it has not, which is why this is not simply a
|
||||
/// blocking receive: a slow or stalled sidecar request must not freeze the
|
||||
/// window with the photograph already decoded and on screen.
|
||||
/// magnitude smaller, so it has usually landed by the time there is a session
|
||||
/// to apply it to — and this takes it straight from the channel. An original
|
||||
/// read from the cache can still beat it, and drawing then showed the
|
||||
/// photograph at its defaults and changed it a moment later. So the first
|
||||
/// 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
|
||||
/// difference is whether it was ever briefly shown at its defaults.
|
||||
/// `still_current` is false once the view has moved to another photograph,
|
||||
/// whose session this sidecar must not be applied to.
|
||||
fn apply_when_ready(
|
||||
window: &AppWindow,
|
||||
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||
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 Some(sidecar) = got {
|
||||
presets::apply_stored_edit(window, &sidecar, session, rows);
|
||||
}
|
||||
redraw(window);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -837,17 +838,37 @@ fn apply_when_ready(
|
||||
let session = session.clone();
|
||||
let rows = rows.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 held = timer.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(50),
|
||||
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();
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(sidecar) = got else { return };
|
||||
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
|
||||
let applied = match got {
|
||||
Some(sidecar) => presets::apply_stored_edit(&w, &sidecar, &session, &rows),
|
||||
None => false,
|
||||
};
|
||||
if applied || !drawn.get() {
|
||||
redraw(&w);
|
||||
}
|
||||
},
|
||||
@@ -1180,6 +1201,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis());
|
||||
|
||||
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_diagnostics(&window, &gpu);
|
||||
|
||||
@@ -1232,6 +1266,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let index = Rc::new(RefCell::new(0usize));
|
||||
// The current develop session, if the file yielded sensor data.
|
||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||
wire_profile_offer(&window, &session);
|
||||
|
||||
// TRACES: FR-PLAT-AND-5
|
||||
// The GPU tier — the first thing given back under memory pressure, and on
|
||||
@@ -1465,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
|
||||
/// changes twice after launch — when the probe reports and as each
|
||||
/// 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) {
|
||||
let set = |w: &AppWindow| {
|
||||
let (line, detail) = inference::about_lines();
|
||||
@@ -1901,17 +1973,31 @@ fn wire_import_and_merge(
|
||||
move || library_for_sources.export_sources(&collections_for_sources.selected()),
|
||||
move || {
|
||||
let conn = library_for_context.session()?;
|
||||
let catalog = library_for_context.catalog();
|
||||
let root = conn.account.root.clone();
|
||||
Some(merge_ui::Context {
|
||||
outbox: export::outbox_dir(&conn.account),
|
||||
conn,
|
||||
names_in: Box::new(move |folder| {
|
||||
catalog
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| library::names_in_folder(c, &root, folder))
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
})
|
||||
},
|
||||
move |w| {
|
||||
// Staged beside its sources: upload it now rather than
|
||||
// on the next sync pass, then look for it, exactly as an
|
||||
// import does.
|
||||
drain_outbox(&library_for_done);
|
||||
w.global::<Library>().invoke_library_rescan();
|
||||
move |w, placed| {
|
||||
// TRACES: FR-MRG-6
|
||||
// In the grid now, from what the merge knows: a scan
|
||||
// started here raced the upload and did not find it.
|
||||
if let Some(placed) = placed {
|
||||
library_ui::catalogue_composite(w, &library_for_done, placed);
|
||||
}
|
||||
// Upload it now rather than on the next sync pass; the
|
||||
// scan that follows the upload gives the row the file id
|
||||
// the server assigned.
|
||||
drain_outbox(&library_for_done, w.as_weak());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2152,6 +2238,15 @@ fn build_render_now(
|
||||
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
|
||||
// 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
|
||||
@@ -2243,6 +2338,13 @@ fn build_render_now(
|
||||
window.set_canvas_draft(draft);
|
||||
window.global::<Levels>().set_provisional(draft);
|
||||
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
|
||||
// rather than the gesture, so a clamped zoom shows the
|
||||
// value that was actually applied.
|
||||
@@ -2503,6 +2605,7 @@ fn build_show(
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
);
|
||||
show_camera_profile(window, l.session.as_ref());
|
||||
|
||||
// The panel is built from what the pipeline reports, so
|
||||
// this code names no operation (FR-DEV-3a).
|
||||
@@ -2560,6 +2663,7 @@ fn build_show(
|
||||
capture.set_exposure("".into());
|
||||
capture.set_dimensions("".into());
|
||||
capture.set_lens("".into());
|
||||
show_camera_profile(window, None);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2647,6 +2751,8 @@ fn wire_remote_open(
|
||||
capture.set_camera("".into());
|
||||
capture.set_exposure("".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
|
||||
// to walk. Shown as 1 of 1 rather than left reading 0.
|
||||
w.set_index(0);
|
||||
@@ -2778,13 +2884,18 @@ fn wire_remote_open(
|
||||
log::debug!("{name}: landed after the view moved on");
|
||||
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_has_load_preview(false);
|
||||
|
||||
let bytes = match got {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
w.set_load_pending(false);
|
||||
w.set_has_load_preview(false);
|
||||
job.fail(e.message.clone());
|
||||
log::warn!("{name}: {e}");
|
||||
// Offline needs its own words. "network error:
|
||||
@@ -2819,6 +2930,14 @@ fn wire_remote_open(
|
||||
capture.set_camera(describe_camera(&l.meta).into());
|
||||
capture.set_exposure(describe_exposure(&l.meta).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 {
|
||||
Some(mut s) => {
|
||||
w.global::<Develop>().set_enabled(true);
|
||||
@@ -2853,16 +2972,19 @@ fn wire_remote_open(
|
||||
// TRACES: FR-CAT-8
|
||||
// The stored edit, if it has landed. It
|
||||
// was started before the download of a
|
||||
// file thousands of times its size, so in
|
||||
// practice it has; `apply_when_ready`
|
||||
// covers the case where it has not rather
|
||||
// file thousands of times its size, but
|
||||
// an original read from the cache can
|
||||
// beat it; `apply_when_ready` holds the
|
||||
// first frame back a moment for it rather
|
||||
// than blocking the UI thread on a socket.
|
||||
let still = current.clone();
|
||||
apply_when_ready(
|
||||
&w,
|
||||
sidecar_rx.clone(),
|
||||
&session,
|
||||
&rows,
|
||||
&redraw,
|
||||
move || still.get() == mine,
|
||||
);
|
||||
// TRACES: FR-UI-4
|
||||
// Under the same magnifier as the last
|
||||
@@ -2875,12 +2997,16 @@ fn wire_remote_open(
|
||||
// moves the point rather than losing it.
|
||||
resume_inspection(&session, &viewport, &inspection);
|
||||
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 => {
|
||||
*session.borrow_mut() = None;
|
||||
rows.set_vec(Vec::<ParamRow>::new());
|
||||
w.global::<Develop>().set_enabled(false);
|
||||
w.set_load_pending(false);
|
||||
w.set_has_load_preview(false);
|
||||
if let Some(image) = l.fallback {
|
||||
w.set_canvas(image);
|
||||
}
|
||||
@@ -2892,6 +3018,8 @@ fn wire_remote_open(
|
||||
log::warn!("{name}: {e}");
|
||||
*session.borrow_mut() = None;
|
||||
w.global::<Develop>().set_enabled(false);
|
||||
w.set_load_pending(false);
|
||||
w.set_has_load_preview(false);
|
||||
w.set_load_error(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(super) fn uncollapsed(image: &str) -> String {
|
||||
}
|
||||
|
||||
/// One grid cell's data, read from the catalog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LibraryCell {
|
||||
pub image_id: i64,
|
||||
pub name: String,
|
||||
@@ -53,6 +53,10 @@ pub struct LibraryCell {
|
||||
pub metadata_state: u8,
|
||||
/// UTC seconds, once EXIF has been read.
|
||||
pub captured_at: Option<i64>,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Width over height, where the catalog knows both: what decides whether
|
||||
/// the cell spans columns. Read with the window, never per cell.
|
||||
pub aspect: Option<f32>,
|
||||
}
|
||||
|
||||
/// Read a window of cells out of the catalog.
|
||||
@@ -346,6 +350,94 @@ pub fn ordinal_of_path(
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Which rows of the catalog are wide enough to span columns, spelled as
|
||||
/// [`crate::library_ui`]'s layout counts them: `w / h >= 1.9`, in integers
|
||||
/// so the index below can be matched term for term. Change one, change the
|
||||
/// other — `the_wide_predicate_is_the_layouts` holds them together.
|
||||
pub(super) const WIDE: &str = "w * 10 >= h * 19";
|
||||
|
||||
/// Make sure the partial index of wide photographs exists.
|
||||
///
|
||||
/// Created on first use rather than by a migration: it holds only the
|
||||
/// panoramas, it changes nothing an older build reads, and a schema bump
|
||||
/// would make every older peer refuse this catalog's snapshot. Keyed on the
|
||||
/// grid's ordering, so the planner can answer "is there any?" from it alone.
|
||||
fn ensure_wide_index(catalog: &Catalog) -> Result<(), dr_catalog::CatalogError> {
|
||||
catalog.connection().execute_batch(&format!(
|
||||
"CREATE INDEX IF NOT EXISTS images_wide
|
||||
ON images(captured_at, source_ref) WHERE {WIDE}"
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// The photographs in the grid's current list that are wide enough to span
|
||||
/// columns: each one's ordinal and aspect, in order.
|
||||
///
|
||||
/// What the layout places them from. Answered from the small side first: a
|
||||
/// library with no panorama in it — the reference library, and every one
|
||||
/// until a merge writes a composite — is told so by one probe of the
|
||||
/// partial index, and nothing else runs. Otherwise one pass over the list,
|
||||
/// numbering it through the ordering the cells are read with, exactly as
|
||||
/// [`ordinal_of_path`] does and for its reason; it runs when what the grid
|
||||
/// lists changes, not per scroll.
|
||||
pub fn wide_ordinals(
|
||||
catalog: &Catalog,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
trash: bool,
|
||||
) -> Result<Vec<(usize, f32)>, dr_catalog::CatalogError> {
|
||||
ensure_wide_index(catalog)?;
|
||||
let any: bool = catalog.connection().query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !any {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (inner, params) = if trash {
|
||||
(
|
||||
format!(
|
||||
"SELECT i.w AS w, i.h AS h,
|
||||
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
|
||||
FROM images i
|
||||
WHERE {TRASHED}"
|
||||
),
|
||||
Vec::new(),
|
||||
)
|
||||
} else {
|
||||
let (clause, scope_params) = scope_clause(catalog, scope)?;
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
let (order, mut params) = grid_order_for(catalog, scope);
|
||||
// The window's parameters first — see `ordinal_of_path`.
|
||||
params.extend(scope_params);
|
||||
(
|
||||
format!(
|
||||
"SELECT i.w AS w, i.h AS h,
|
||||
row_number() OVER ({order}) - 1 AS ord
|
||||
FROM images i
|
||||
WHERE {VISIBLE}{rated}{folded}{clause}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
};
|
||||
let mut stmt = catalog.connection().prepare(&format!(
|
||||
"SELECT ord, w, h FROM ({inner}) WHERE {WIDE} ORDER BY ord"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?.max(0) as usize,
|
||||
r.get::<_, i64>(1)? as f32 / r.get::<_, i64>(2)?.max(1) as f32,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The columns every windowed read selects, in the order [`row_to_cell`] reads
|
||||
/// them.
|
||||
///
|
||||
@@ -353,7 +445,7 @@ pub fn ordinal_of_path(
|
||||
/// the one column that is *not* here stays conspicuous. See
|
||||
/// [`attach_file_ids`] for why the server's file id is fetched separately.
|
||||
pub(super) const CELL_COLUMNS: &str =
|
||||
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at";
|
||||
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at, i.w, i.h";
|
||||
|
||||
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
|
||||
///
|
||||
@@ -368,6 +460,10 @@ pub(super) fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
|
||||
size: r.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
|
||||
metadata_state: r.get::<_, i64>(3)? as u8,
|
||||
captured_at: r.get(4)?,
|
||||
aspect: match (r.get::<_, Option<i64>>(5)?, r.get::<_, Option<i64>>(6)?) {
|
||||
(Some(w), Some(h)) if w > 0 && h > 0 => Some(w as f32 / h as f32),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -944,6 +1040,93 @@ mod tests {
|
||||
// --- the trash view (FR-CAT-15) ---------------------------------------
|
||||
|
||||
/// Mark one image trashed, at a given instant.
|
||||
fn shaped(catalog: &Catalog, path: &str, w: i64, h: i64) {
|
||||
let n = catalog
|
||||
.connection()
|
||||
.execute(
|
||||
"UPDATE images SET w = ?2, h = ?3 WHERE source_ref = ?1",
|
||||
rusqlite::params![path, w, h],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n, 1, "fixture should have shaped exactly {path}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wide_photographs_are_found_at_their_ordinals() {
|
||||
let catalog = with_images(20);
|
||||
assert!(
|
||||
wide_ordinals(&catalog, None, &RatingFilter::default(), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
shaped(&catalog, "PhotosRaw/img005.CR2", 4000, 1000);
|
||||
shaped(&catalog, "PhotosRaw/img012.CR2", 3000, 1400);
|
||||
shaped(&catalog, "PhotosRaw/img013.CR2", 3000, 2000);
|
||||
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
|
||||
assert_eq!(
|
||||
wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(),
|
||||
vec![5, 12],
|
||||
"{wide:?}"
|
||||
);
|
||||
assert!((wide[0].1 - 4.0).abs() < 1e-6);
|
||||
|
||||
// The ordinals are the grid's: a frame trashed before them moves
|
||||
// them up, and in the trash only the trash counts.
|
||||
trash_at(&catalog, "PhotosRaw/img003.CR2", 1);
|
||||
trash_at(&catalog, "PhotosRaw/img012.CR2", 2);
|
||||
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
|
||||
assert_eq!(wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![4]);
|
||||
let trashed = wide_ordinals(&catalog, None, &RatingFilter::default(), true).unwrap();
|
||||
// Newest deletion first: img012 then img003.
|
||||
assert_eq!(trashed.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_with_no_panorama_is_told_so_by_the_index() {
|
||||
let catalog = with_images(20);
|
||||
wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
|
||||
let plan: Vec<String> = catalog
|
||||
.connection()
|
||||
.prepare(&format!(
|
||||
"EXPLAIN QUERY PLAN SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"
|
||||
))
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get::<_, String>(3))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
let plan = plan.join(" | ");
|
||||
assert!(plan.contains("images_wide"), "{plan}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wide_predicate_is_the_layouts() {
|
||||
use crate::library_ui::layout::natural_span;
|
||||
let catalog = with_images(1);
|
||||
for (w, h) in [
|
||||
(19, 10),
|
||||
(189, 100),
|
||||
(190, 100),
|
||||
(191, 100),
|
||||
(6000, 4000),
|
||||
(4000, 6000),
|
||||
(22_993, 5_980),
|
||||
(1_899, 1_000),
|
||||
(1_900, 1_000),
|
||||
] {
|
||||
let sql: bool = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
&format!("SELECT {}", WIDE.replace('w', "?1").replace('h', "?2")),
|
||||
rusqlite::params![w, h],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let rust = natural_span(Some(w as f32 / h as f32)) > 1;
|
||||
assert_eq!(sql, rust, "{w}×{h}");
|
||||
}
|
||||
}
|
||||
|
||||
fn trash_at(catalog: &Catalog, path: &str, when: i64) {
|
||||
let n = catalog
|
||||
.connection()
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
//! TRACES: FR-MRG-6
|
||||
//! A merged panorama in the catalog the moment it is written, rather than
|
||||
//! whenever a scan next happens to find it.
|
||||
//!
|
||||
//! # Why the merge catalogues its own file
|
||||
//!
|
||||
//! The composite reaches the library through the outbox, like an export,
|
||||
//! and the grid used to learn of it the way it learns of anything: a rescan,
|
||||
//! fired as the merge finished. That scan raced the upload it followed. On a
|
||||
//! folder library the copy of an 800 MB file was still running when the
|
||||
//! folder was listed; on Nextcloud the upload takes minutes. Either way the
|
||||
//! listing did not have the file, the folder's validator was recorded as it
|
||||
//! stood, and nothing looked again until the next sync pass — the panorama
|
||||
//! the photographer had just watched being made was not in the grid.
|
||||
//!
|
||||
//! So the job hands the library what it knows — the name the file will have,
|
||||
//! the size of the picture, when it was taken — and the row is written at
|
||||
//! once, in one transaction. The upload later supplies what only the server
|
||||
//! knows, its file id, through [`record_uploaded`]; the scan that follows
|
||||
//! finds a row already there and updates it in place, on the same
|
||||
//! `(root_id, source_ref)` key it would have inserted under.
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
|
||||
use super::scan::now_secs;
|
||||
|
||||
/// What the catalog is told about a composite before any scan has seen it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CompositeRow {
|
||||
/// Where it will be in the library, spelled as the scan spells it.
|
||||
pub source_ref: String,
|
||||
/// The picture it opens on — the crop, where the merge cropped — upright.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// When it was taken: the middle of its sweep, as the DNG says. `None`
|
||||
/// where no frame carried a time, and then the earliest of `sources` as
|
||||
/// the catalog has them.
|
||||
pub captured_at: Option<i64>,
|
||||
pub captured_offset: Option<i32>,
|
||||
pub camera: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
pub iso: Option<u32>,
|
||||
pub file_size: u64,
|
||||
/// The frames it was merged from, as source refs.
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
/// Write the composite's row, and return its image id.
|
||||
///
|
||||
/// One transaction: the root, the capture time the sources lend it if it has
|
||||
/// none of its own, and the row. At `metadata_state = 2`, since everything a
|
||||
/// header read would add is already here — the sweep has no reason to fetch
|
||||
/// the tail of an 800 MB file for a date the merge wrote into it.
|
||||
///
|
||||
/// A row already under the name is brought up to date rather than
|
||||
/// duplicated. The merge picks a name the catalog does not hold, so that is
|
||||
/// a safety net: the file at that name is the one just written.
|
||||
pub fn catalogue_composite(
|
||||
catalog: &Catalog,
|
||||
root: &str,
|
||||
row: &CompositeRow,
|
||||
) -> Result<i64, dr_catalog::CatalogError> {
|
||||
let conn = catalog.connection();
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO roots(kind, label, last_seen) VALUES ('remote', ?1, ?2)
|
||||
ON CONFLICT DO NOTHING",
|
||||
rusqlite::params![root, now_secs()],
|
||||
)?;
|
||||
let root_id: i64 = tx.query_row(
|
||||
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
||||
[root],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
// The folder, if a scan has recorded it; the sources are in it, so it
|
||||
// almost always has. `None` is what the scan itself writes for a file at
|
||||
// the root, and the next scan fills it either way.
|
||||
let folder_id: Option<i64> = match row.source_ref.rsplit_once('/') {
|
||||
Some((parent, _)) => tx
|
||||
.query_row(
|
||||
"SELECT id FROM folders WHERE root_id = ?1 AND path = ?2",
|
||||
rusqlite::params![root_id, parent],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// The sources' earliest, where the composite carries no time. One
|
||||
// aggregate over the handful of frames, by their key.
|
||||
let captured_at = match row.captured_at {
|
||||
Some(t) => Some(t),
|
||||
None if row.sources.is_empty() => None,
|
||||
None => {
|
||||
let placeholders = std::iter::repeat_n("?", row.sources.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut params: Vec<rusqlite::types::Value> =
|
||||
vec![rusqlite::types::Value::Integer(root_id)];
|
||||
params.extend(
|
||||
row.sources
|
||||
.iter()
|
||||
.map(|s| rusqlite::types::Value::Text(s.clone())),
|
||||
);
|
||||
tx.query_row(
|
||||
&format!(
|
||||
"SELECT min(captured_at) FROM images
|
||||
WHERE root_id = ? AND source_ref IN ({placeholders})"
|
||||
),
|
||||
rusqlite::params_from_iter(params.iter()),
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
)?
|
||||
}
|
||||
};
|
||||
|
||||
let format = row
|
||||
.source_ref
|
||||
.rsplit_once('.')
|
||||
.map(|(_, e)| e.to_ascii_lowercase());
|
||||
tx.execute(
|
||||
"INSERT INTO images(root_id, folder_id, source_ref, format, w, h,
|
||||
captured_at, captured_offset, camera, lens, iso,
|
||||
file_size, availability, metadata_state, added_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, 2, ?13)
|
||||
ON CONFLICT(root_id, source_ref) DO UPDATE SET
|
||||
folder_id = coalesce(excluded.folder_id, images.folder_id),
|
||||
w = excluded.w,
|
||||
h = excluded.h,
|
||||
captured_at = coalesce(excluded.captured_at, images.captured_at),
|
||||
captured_offset = coalesce(excluded.captured_offset, images.captured_offset),
|
||||
camera = coalesce(excluded.camera, images.camera),
|
||||
lens = coalesce(excluded.lens, images.lens),
|
||||
iso = coalesce(excluded.iso, images.iso),
|
||||
file_size = excluded.file_size,
|
||||
metadata_state = 2",
|
||||
rusqlite::params![
|
||||
root_id,
|
||||
folder_id,
|
||||
row.source_ref,
|
||||
format,
|
||||
row.width as i64,
|
||||
row.height as i64,
|
||||
captured_at,
|
||||
row.captured_offset,
|
||||
row.camera,
|
||||
row.lens,
|
||||
row.iso.map(i64::from),
|
||||
row.file_size as i64,
|
||||
now_secs(),
|
||||
],
|
||||
)?;
|
||||
let image_id: i64 = tx.query_row(
|
||||
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
|
||||
rusqlite::params![root_id, row.source_ref],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(image_id)
|
||||
}
|
||||
|
||||
/// The image a library path names, if the catalog has it. What the upload
|
||||
/// asks before it spends a listing on a file: a file the catalog has no row
|
||||
/// for is the scan's to find, and only a row written ahead of the scan is
|
||||
/// waiting for its server identity.
|
||||
pub fn image_at(catalog: &Catalog, root: &str, source_ref: &str) -> Option<i64> {
|
||||
catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT i.id FROM images i JOIN roots r ON r.id = i.root_id
|
||||
WHERE r.label = ?1 AND r.kind = 'remote' AND i.source_ref = ?2",
|
||||
rusqlite::params![root, source_ref],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The names the catalog holds directly in `folder` of the library at
|
||||
/// `root`, whatever their state — what a new file there must not be called.
|
||||
///
|
||||
/// A range over the `(root_id, source_ref)` key, `/` to `0` being the byte
|
||||
/// after it, so a folder of a thousand costs a seek and a thousand index
|
||||
/// entries, not the library.
|
||||
pub fn names_in_folder(
|
||||
catalog: &Catalog,
|
||||
root: &str,
|
||||
folder: &str,
|
||||
) -> std::collections::HashSet<String> {
|
||||
let folder = folder.trim_matches('/');
|
||||
let (from, to) = if folder.is_empty() {
|
||||
(String::new(), "\u{10ffff}".to_string())
|
||||
} else {
|
||||
(format!("{folder}/"), format!("{folder}0"))
|
||||
};
|
||||
let Ok(mut stmt) = catalog.connection().prepare(
|
||||
"SELECT i.source_ref FROM images i JOIN roots r ON r.id = i.root_id
|
||||
WHERE r.label = ?1 AND r.kind = 'remote'
|
||||
AND i.source_ref >= ?2 AND i.source_ref < ?3",
|
||||
) else {
|
||||
return Default::default();
|
||||
};
|
||||
let Ok(rows) = stmt.query_map(rusqlite::params![root, from, to], |r| r.get::<_, String>(0))
|
||||
else {
|
||||
return Default::default();
|
||||
};
|
||||
rows.flatten()
|
||||
.filter_map(|p| {
|
||||
let name = p.get(from.len()..)?;
|
||||
(!name.contains('/')).then(|| name.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Give a catalogued file the identity the server assigned it on upload:
|
||||
/// its file id, which the thumbnail store keys on, and its validator.
|
||||
///
|
||||
/// The same row the scan's `remote` upsert writes, so whichever of the two
|
||||
/// runs second agrees with the first.
|
||||
pub fn record_uploaded(
|
||||
catalog: &Catalog,
|
||||
image_id: i64,
|
||||
entry: &dr_sync::RemoteEntry,
|
||||
) -> Result<(), dr_catalog::CatalogError> {
|
||||
let conn = catalog.connection();
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
if let dr_sync::RemoteId::Stable(file_id) = entry.id {
|
||||
tx.execute(
|
||||
"INSERT INTO remote(image_id, file_id, etag, remote_path)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(image_id) DO UPDATE SET
|
||||
file_id = excluded.file_id, etag = excluded.etag,
|
||||
remote_path = excluded.remote_path",
|
||||
rusqlite::params![
|
||||
image_id,
|
||||
file_id as i64,
|
||||
entry.validator.as_str(),
|
||||
entry.path.as_str()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE images SET file_size = ?2 WHERE id = ?1 AND file_size IS NOT ?2",
|
||||
rusqlite::params![image_id, entry.size as i64],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::library::read_cells;
|
||||
use crate::library::scan::persist;
|
||||
use crate::library::test_support::entry;
|
||||
use dr_sync::RemotePath;
|
||||
|
||||
/// Twelve frames of a sweep a second apart, scanned and dated.
|
||||
fn a_sweep() -> Catalog {
|
||||
let catalog = Catalog::in_memory().unwrap();
|
||||
let images = (0..12)
|
||||
.map(|i| {
|
||||
entry(
|
||||
&format!("Alps/_MG_{:04}.CR2", 8320 + i),
|
||||
100 + i,
|
||||
30_000_000,
|
||||
)
|
||||
})
|
||||
.chain(std::iter::once(entry("Alps/later.CR2", 200, 30_000_000)))
|
||||
.collect();
|
||||
persist(
|
||||
&catalog,
|
||||
"",
|
||||
&dr_sync::ScanResult {
|
||||
images,
|
||||
directories: vec![(RemotePath::new("Alps"), dr_sync::Validator::new("e1"))],
|
||||
progress: Default::default(),
|
||||
sidecars: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..12 {
|
||||
catalog
|
||||
.connection()
|
||||
.execute(
|
||||
"UPDATE images SET captured_at = ?2, metadata_state = 2
|
||||
WHERE source_ref = ?1",
|
||||
rusqlite::params![format!("Alps/_MG_{:04}.CR2", 8320 + i), 1_000 + i],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
catalog
|
||||
.connection()
|
||||
.execute(
|
||||
"UPDATE images SET captured_at = 5000 WHERE source_ref = 'Alps/later.CR2'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
catalog
|
||||
}
|
||||
|
||||
fn composite(captured_at: Option<i64>) -> CompositeRow {
|
||||
CompositeRow {
|
||||
source_ref: "Alps/_MG_8320-pano.dng".into(),
|
||||
width: 22_000,
|
||||
height: 5_600,
|
||||
captured_at,
|
||||
captured_offset: None,
|
||||
camera: Some("Canon EOS 6D".into()),
|
||||
lens: None,
|
||||
iso: Some(100),
|
||||
file_size: 800_000_000,
|
||||
sources: (0..12)
|
||||
.map(|i| format!("Alps/_MG_{:04}.CR2", 8320 + i))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn names(catalog: &Catalog) -> Vec<String> {
|
||||
read_cells(catalog, 0, 100)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_composite_sits_among_its_sources_the_moment_it_is_written() {
|
||||
let catalog = a_sweep();
|
||||
catalogue_composite(&catalog, "", &composite(Some(1_005))).unwrap();
|
||||
let names = names(&catalog);
|
||||
let at = names.iter().position(|n| n == "_MG_8320-pano.dng").unwrap();
|
||||
// Dated at the middle of the sweep, so it is among the frames and not
|
||||
// after the photograph taken an hour later.
|
||||
assert!(at > 0 && at < 12, "{names:?}");
|
||||
assert_eq!(names.last().unwrap(), "later.CR2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_composite_with_no_time_takes_its_earliest_source() {
|
||||
let catalog = a_sweep();
|
||||
let id = catalogue_composite(&catalog, "", &composite(None)).unwrap();
|
||||
let (t, w, h, state): (Option<i64>, i64, i64, i64) = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT captured_at, w, h, metadata_state FROM images WHERE id = ?1",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t, Some(1_000));
|
||||
assert_eq!((w, h, state), (22_000, 5_600, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_that_ran_before_the_upload_does_not_lose_the_row() {
|
||||
// Nextcloud: the merge finishes, the row is written, and a sync pass
|
||||
// lists the folder before the upload has finished — the file is not
|
||||
// on the server yet.
|
||||
let catalog = a_sweep();
|
||||
let id = catalogue_composite(&catalog, "", &composite(Some(1_005))).unwrap();
|
||||
persist(
|
||||
&catalog,
|
||||
"",
|
||||
&dr_sync::ScanResult {
|
||||
images: vec![entry("Alps/later.CR2", 200, 30_000_000)],
|
||||
directories: vec![(RemotePath::new("Alps"), dr_sync::Validator::new("e2"))],
|
||||
progress: Default::default(),
|
||||
sidecars: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(names(&catalog).contains(&"_MG_8320-pano.dng".to_string()));
|
||||
|
||||
// The upload lands and the server names it; then the scan finds it.
|
||||
let listed = entry("Alps/_MG_8320-pano.dng", 9_001, 812_345_678);
|
||||
record_uploaded(&catalog, id, &listed).unwrap();
|
||||
persist(
|
||||
&catalog,
|
||||
"",
|
||||
&dr_sync::ScanResult {
|
||||
images: vec![listed],
|
||||
directories: vec![(RemotePath::new("Alps"), dr_sync::Validator::new("e3"))],
|
||||
progress: Default::default(),
|
||||
sidecars: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let rows: Vec<(i64, Option<i64>, i64, Option<i64>)> = catalog
|
||||
.connection()
|
||||
.prepare(
|
||||
"SELECT i.id, r.file_id, i.file_size, i.captured_at FROM images i
|
||||
LEFT JOIN remote r ON r.image_id = i.id
|
||||
WHERE i.source_ref = 'Alps/_MG_8320-pano.dng'",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
assert_eq!(rows, vec![(id, Some(9_001), 812_345_678, Some(1_005))]);
|
||||
let cell = read_cells(&catalog, 0, 100)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|c| c.image_id == id)
|
||||
.unwrap();
|
||||
assert_eq!(cell.file_id, Some(9_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_again_under_the_same_name_updates_the_one_row() {
|
||||
let catalog = a_sweep();
|
||||
let first = catalogue_composite(&catalog, "", &composite(Some(1_005))).unwrap();
|
||||
let mut again = composite(Some(1_006));
|
||||
again.width = 21_000;
|
||||
let second = catalogue_composite(&catalog, "", &again).unwrap();
|
||||
assert_eq!(first, second);
|
||||
let w: i64 = catalog
|
||||
.connection()
|
||||
.query_row("SELECT w FROM images WHERE id = ?1", [first], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(w, 21_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_in_a_folder_are_its_own_and_not_its_subfolders() {
|
||||
let catalog = a_sweep();
|
||||
catalogue_composite(&catalog, "", &composite(Some(1_005))).unwrap();
|
||||
persist(
|
||||
&catalog,
|
||||
"",
|
||||
&dr_sync::ScanResult {
|
||||
images: vec![
|
||||
entry("Alps/2019/deep.CR2", 300, 1),
|
||||
entry("Alpsine/near.CR2", 301, 1),
|
||||
],
|
||||
directories: Vec::new(),
|
||||
progress: Default::default(),
|
||||
sidecars: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let names = names_in_folder(&catalog, "", "Alps");
|
||||
assert!(names.contains("_MG_8320-pano.dng"));
|
||||
assert!(names.contains("_MG_8320.CR2"));
|
||||
assert!(!names
|
||||
.iter()
|
||||
.any(|n| n.contains("deep") || n.contains("near")));
|
||||
assert_eq!(names.len(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_row_the_merge_wrote_is_found_for_the_upload() {
|
||||
let catalog = a_sweep();
|
||||
assert!(image_at(&catalog, "", "Alps/_MG_8320-pano.dng").is_none());
|
||||
let id = catalogue_composite(&catalog, "", &composite(Some(1_005))).unwrap();
|
||||
assert_eq!(image_at(&catalog, "", "Alps/_MG_8320-pano.dng"), Some(id));
|
||||
assert!(image_at(&catalog, "elsewhere", "Alps/_MG_8320-pano.dng").is_none());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
//! `library` needs to change.
|
||||
|
||||
mod cells;
|
||||
mod composite;
|
||||
mod filters;
|
||||
mod paths;
|
||||
mod scan;
|
||||
@@ -39,6 +40,7 @@ mod thumbnails_gen;
|
||||
mod xmp;
|
||||
|
||||
pub use cells::*;
|
||||
pub use composite::*;
|
||||
pub use filters::*;
|
||||
pub use paths::*;
|
||||
pub use scan::*;
|
||||
@@ -84,6 +86,21 @@ pub(crate) mod test_support {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a scan's listing of `images` under `root`, as a scan would.
|
||||
pub fn scanned_listing(catalog: &Catalog, root: &str, images: Vec<RemoteEntry>) {
|
||||
persist(
|
||||
catalog,
|
||||
root,
|
||||
&dr_sync::ScanResult {
|
||||
images,
|
||||
directories: Vec::new(),
|
||||
progress: Default::default(),
|
||||
sidecars: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A catalog with `n` images, ready to file into collections.
|
||||
pub fn with_images(n: usize) -> Catalog {
|
||||
let catalog = Catalog::in_memory().unwrap();
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn place_path(account: &Account) -> PathBuf {
|
||||
/// desktop declares nothing and takes the platform's data directory from
|
||||
/// `dr_plat::dirs` — XDG on Linux, `%LOCALAPPDATA%` on Windows — which keeps
|
||||
/// 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() {
|
||||
Some(declared) => declared.join("darkroom"),
|
||||
None => dr_plat::base_dir(dr_plat::Base::Data),
|
||||
|
||||
@@ -28,6 +28,23 @@ pub struct MetadataFound {
|
||||
pub camera: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
pub iso: Option<u32>,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Width and height as the photograph is seen — orientation applied —
|
||||
/// where the header says: what gives a panorama from elsewhere, a
|
||||
/// stitch from another program or a phone's sweep, its wide cell.
|
||||
pub size: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl MetadataFound {
|
||||
/// The upright size a header describes.
|
||||
pub fn upright_size(md: &dr_decode::Metadata) -> Option<(u32, u32)> {
|
||||
let (w, h) = (md.width?, md.height?);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let turned = md.orientation.is_some_and(|o| o.quarter_turns % 2 == 1);
|
||||
Some(if turned { (h, w) } else { (w, h) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a batch of dates and tell the UI, draining `found`.
|
||||
@@ -140,7 +157,9 @@ pub fn write_metadata(
|
||||
camera = coalesce(?4, camera),
|
||||
lens = coalesce(?5, lens),
|
||||
iso = coalesce(?6, iso),
|
||||
metadata_state = max(metadata_state, ?7)
|
||||
metadata_state = max(metadata_state, ?7),
|
||||
w = coalesce(?8, w),
|
||||
h = coalesce(?9, h)
|
||||
WHERE id = ?1",
|
||||
rusqlite::params![
|
||||
m.image_id,
|
||||
@@ -150,6 +169,8 @@ pub fn write_metadata(
|
||||
m.lens,
|
||||
m.iso,
|
||||
state,
|
||||
m.size.map(|s| s.0),
|
||||
m.size.map(|s| s.1),
|
||||
],
|
||||
)?;
|
||||
if state == 2 {
|
||||
@@ -404,6 +425,7 @@ pub fn spawn_sweep(conn: Connection, catalog_path: PathBuf) -> Receiver<SweepMes
|
||||
camera: None,
|
||||
lens: None,
|
||||
iso: None,
|
||||
size: None,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -500,11 +522,16 @@ pub(super) fn flush_sweep(catalog: &Catalog, found: &mut Vec<MetadataFound>) {
|
||||
.filter(|m| m.captured_at.is_none())
|
||||
.map(|m| m.image_id)
|
||||
.collect();
|
||||
for id in ids {
|
||||
for &id in &ids {
|
||||
let _ = catalog
|
||||
.connection()
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -874,6 +901,20 @@ pub(super) fn thumbnails_outstanding(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_header_gives_the_size_the_photograph_is_seen_at() {
|
||||
let mut md = dr_decode::Metadata {
|
||||
width: Some(9000),
|
||||
height: Some(3000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(MetadataFound::upright_size(&md), Some((9000, 3000)));
|
||||
md.orientation = Some(dr_types::Orientation::from_exif(6));
|
||||
assert_eq!(MetadataFound::upright_size(&md), Some((3000, 9000)));
|
||||
md.width = None;
|
||||
assert_eq!(MetadataFound::upright_size(&md), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_all_preserves_order_regardless_of_completion() {
|
||||
// The ordering guarantee is what lets a caller pair results back to
|
||||
|
||||
@@ -517,6 +517,7 @@ pub(super) async fn collect_metadata(
|
||||
captured_at: md.captured_at,
|
||||
captured_offset: md.captured_offset,
|
||||
camera: camera_label(md.make.as_deref(), md.model.as_deref()),
|
||||
size: MetadataFound::upright_size(&md),
|
||||
lens: md.lens.map(|l| l.trim().to_string()),
|
||||
iso: md.iso,
|
||||
});
|
||||
|
||||
@@ -113,6 +113,25 @@ pub struct LibraryController {
|
||||
/// two states apart — without it a carried thumbnail either blocks the
|
||||
/// sharper fetch forever or is re-fetched on every scroll.
|
||||
pub(super) thumb_class: RefCell<Vec<Option<dr_thumbs::ThumbSize>>>,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Thumbnails a merge made for a composite it has just catalogued, by
|
||||
/// image, decoded and at each class it made.
|
||||
///
|
||||
/// The grid's own path keys the store on the server's file id, and a
|
||||
/// composite has none until its upload lands — minutes, on Nextcloud. The
|
||||
/// merge's pixels are drawn from here until then, so the cell is not
|
||||
/// blank in the meantime; after, the store holds the same ones.
|
||||
pub(super) merged_thumbs:
|
||||
RefCell<std::collections::HashMap<i64, Vec<(dr_thumbs::ThumbSize, slint::Image)>>>,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Where each photograph of the current list sits in the grid — see
|
||||
/// [`super::layout`]. Rebuilt on every window load from `wide` and the
|
||||
/// column count, which is arithmetic over a handful of panoramas.
|
||||
pub(super) layout: RefCell<super::layout::Layout>,
|
||||
/// The wide photographs of the current list, `(ordinal, natural span)`,
|
||||
/// or `None` when they have to be read again: what the list is changed,
|
||||
/// or a window found them out of date.
|
||||
pub(super) wide: RefCell<Option<Vec<(usize, usize)>>>,
|
||||
/// Where in the catalog the current window starts. Scrubbing moves this.
|
||||
pub(super) offset: RefCell<usize>,
|
||||
/// The first visible ordinal, kept so returning from the develop view lands
|
||||
@@ -449,6 +468,9 @@ impl LibraryController {
|
||||
needs_metadata: RefCell::new(Vec::new()),
|
||||
captured_at: RefCell::new(Vec::new()),
|
||||
thumb_class: RefCell::new(Vec::new()),
|
||||
merged_thumbs: RefCell::new(Default::default()),
|
||||
layout: RefCell::new(super::layout::Layout::uniform(1, 0)),
|
||||
wide: RefCell::new(None),
|
||||
offset: RefCell::new(0),
|
||||
resume_at: std::cell::Cell::new(0),
|
||||
roll_open: std::cell::Cell::new(None),
|
||||
|
||||
@@ -814,19 +814,20 @@ pub(super) fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
/// lands in: a scrolled window would otherwise show no date at all until
|
||||
/// the next month began.
|
||||
pub(super) fn period_headings(
|
||||
months: impl Iterator<Item = Option<(i64, i64)>>,
|
||||
offset: usize,
|
||||
months: impl Iterator<Item = (Option<(i64, i64)>, usize)>,
|
||||
columns: usize,
|
||||
) -> Vec<String> {
|
||||
let columns = columns.max(1);
|
||||
let mut announced: Option<(i64, i64)> = None;
|
||||
months
|
||||
.enumerate()
|
||||
.map(|(i, month)| {
|
||||
.map(|(i, (month, slot))| {
|
||||
let Some((y, m)) = month else {
|
||||
return String::new();
|
||||
};
|
||||
let begins_row = (i + offset).is_multiple_of(columns);
|
||||
// By slot, not ordinal: a panorama's cell pushes what follows it
|
||||
// along (FR-MRG-6).
|
||||
let begins_row = slot.is_multiple_of(columns);
|
||||
if i == 0 || (begins_row && announced != Some((y, m))) {
|
||||
announced = Some((y, m));
|
||||
format!("{} {y}", month_name(m))
|
||||
@@ -863,6 +864,29 @@ fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every cell one slot, the window starting at `offset`.
|
||||
fn at_slots<const N: usize>(
|
||||
months: [Option<(i64, i64)>; N],
|
||||
offset: usize,
|
||||
) -> impl Iterator<Item = (Option<(i64, i64)>, usize)> {
|
||||
months
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(move |(i, m)| (m, offset + i))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_month_is_announced_on_the_row_a_panorama_pushed_it_to() {
|
||||
// Four columns; a panorama spans the first three slots of row two,
|
||||
// and the frame after it sits in the fourth. February's first frame
|
||||
// is pushed to the start of row three, and is announced there.
|
||||
let jan = Some((2026, 1));
|
||||
let feb = Some((2026, 2));
|
||||
let cells = [(jan, 0), (jan, 1), (jan, 2), (jan, 4), (jan, 7), (feb, 8)];
|
||||
let h = period_headings(cells.into_iter(), 4);
|
||||
assert_eq!(h, ["January 2026", "", "", "", "", "February 2026"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_month_that_begins_mid_row_is_still_announced() {
|
||||
// Four columns; August starts in the second column of the second
|
||||
@@ -877,7 +901,7 @@ mod tests {
|
||||
aug, aug, oct, oct, //
|
||||
oct,
|
||||
];
|
||||
let h = period_headings(months.into_iter(), 0, 4);
|
||||
let h = period_headings(at_slots(months, 0), 4);
|
||||
assert_eq!(h[0], "March 2024");
|
||||
assert_eq!(h[4], "", "still March at the top of row two");
|
||||
assert_eq!(h[5], "", "never mid-row");
|
||||
@@ -892,12 +916,12 @@ mod tests {
|
||||
let feb = Some((2026, 2));
|
||||
// Offset 2 into a 4-column grid: the window's first cell is in the
|
||||
// third column, and the row after it begins a new month.
|
||||
let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4);
|
||||
let h = period_headings(at_slots([jan, jan, feb, feb, feb], 2), 4);
|
||||
assert_eq!(h[0], "January 2026");
|
||||
assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2");
|
||||
assert_eq!(h[3], "");
|
||||
// Undated cells carry nothing and announce nothing.
|
||||
let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4);
|
||||
let h = period_headings(at_slots([None, jan, None, None, jan], 0), 4);
|
||||
assert_eq!(h[0], "");
|
||||
assert_eq!(h[4], "January 2026");
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use super::timeline::{
|
||||
capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place,
|
||||
zoomed_span,
|
||||
};
|
||||
use super::window::{bring_window_to, load_window, mark_open, window_move};
|
||||
use super::window::{bring_window_to, load_window, mark_open, show_cursor, window_move};
|
||||
|
||||
/// Walk the keyboard cursor through the library — the arrow keys.
|
||||
///
|
||||
@@ -109,7 +109,7 @@ fn place_cursor(
|
||||
}
|
||||
|
||||
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
|
||||
window.global::<Library>().set_library_cursor(at as i32);
|
||||
show_cursor(window, ctl, at);
|
||||
}
|
||||
|
||||
/// Where a step of `delta` along the roll lands, as a library ordinal, from
|
||||
@@ -419,6 +419,33 @@ fn wire_grid_cursor_and_zoom(
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-MRG-6
|
||||
// Up and down, by rows: with a panorama's cell in the grid a row is not
|
||||
// a fixed number of photographs, so the layout says what is under the
|
||||
// cursor that many rows on, and the step is the distance to it.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window
|
||||
.global::<Library>()
|
||||
.on_library_move_cursor_rows(move |rows, extend| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let delta = match coll.cursor() {
|
||||
Some(from) => {
|
||||
let to = ctl.layout.borrow().step_rows(from, rows as isize);
|
||||
to as i32 - from as i32
|
||||
}
|
||||
// The first press takes hold of the grid, whichever key.
|
||||
None => rows,
|
||||
};
|
||||
if delta == 0 && coll.cursor().is_some() {
|
||||
return;
|
||||
}
|
||||
move_cursor(&w, &ctl, &coll, delta, extend);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
@@ -635,9 +662,18 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let ctl = ctl.clone();
|
||||
window
|
||||
.global::<Library>()
|
||||
.on_library_scrolled(move |first_visible| {
|
||||
.on_library_scrolled(move |first_slot| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let first_visible = first_visible.max(0) as usize;
|
||||
// The grid reports the first slot of its first visible row;
|
||||
// the photograph there is what everything below means
|
||||
// (FR-MRG-6).
|
||||
let first_slot = first_slot.max(0) as usize;
|
||||
let total = w.global::<Library>().get_library_total().max(0) as usize;
|
||||
let first_visible = ctl
|
||||
.layout
|
||||
.borrow()
|
||||
.ordinal_at(first_slot)
|
||||
.min(total.saturating_sub(1));
|
||||
|
||||
// **A report from a grid that is not on screen is not a scroll.**
|
||||
//
|
||||
@@ -693,7 +729,7 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// hand. Without this they each rebuilt against whatever `scroll-to`
|
||||
// was last *set* to — a stale scrub, or zero — and landed there.
|
||||
w.global::<Library>()
|
||||
.set_library_scroll_to(first_visible as i32);
|
||||
.set_library_scroll_to(first_slot as i32);
|
||||
|
||||
// Move the timeline marker with the view. Scrolling the grid is a
|
||||
// way of moving through time just as scrubbing is, and a marker
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! TRACES: FR-MRG-6 | FR-CAT-4
|
||||
//! Where each photograph sits in the grid: the one place rows are computed.
|
||||
//!
|
||||
//! # Slots
|
||||
//!
|
||||
//! The grid is a lattice of `columns` slots per row, and until panoramas had
|
||||
//! cells of their own a photograph's slot was its ordinal — row
|
||||
//! `ordinal / columns`, column `ordinal % columns`, spelled out in half a
|
||||
//! dozen places in the markup and in Rust. A photograph at least about twice
|
||||
//! as wide as it is tall now takes two, three or four slots side by side —
|
||||
//! the class [`natural_span`] picks from its aspect, which also picks its
|
||||
//! thumbnail's size class — and a slot is no longer an ordinal: every cell after a wide one
|
||||
//! is pushed along, and a wide cell that would not fit in what is left of a
|
||||
//! row starts the next one and leaves the rest of its row empty. Reading
|
||||
//! order is kept — nothing later is moved up into the gap — so the arrows,
|
||||
//! a shift-click's run and a scrub all still mean what they meant.
|
||||
//!
|
||||
//! Everything that turns an ordinal into a place on screen, or a place on
|
||||
//! screen into an ordinal, asks [`Layout`]; the markup draws each cell at
|
||||
//! the slot Rust gives it and sizes the scrollbar from [`Layout::total_slots`].
|
||||
//!
|
||||
//! # What it costs
|
||||
//!
|
||||
//! Where a wide photograph sits depends on every wide photograph before it,
|
||||
//! so the layout is built from the ordinals of all of them in the current
|
||||
//! view, not from the loaded window. Those are few — a library has a handful
|
||||
//! of panoramas among thousands of frames — and they are read in one query,
|
||||
//! and only when what the grid lists changes (`LibraryFacts`), or when the
|
||||
//! window finds them out of date. The window itself says how wide each of
|
||||
//! its own photographs is: the aspect comes with the cells' own read.
|
||||
//!
|
||||
//! # On the tablet, and with few columns
|
||||
//!
|
||||
//! A wide photograph takes the whole row there: on a touch-first device
|
||||
//! always, and anywhere the columns are too few to put it beside anything.
|
||||
|
||||
/// The aspect from which a photograph takes more than one slot, and the
|
||||
/// classes above it: `SPAN_FROM[k]` is where a span of `k + 2` begins.
|
||||
///
|
||||
/// # Why these
|
||||
///
|
||||
/// A cell `s` slots wide is about `s:1`, and the thumbnail is fitted inside
|
||||
/// it, so a photograph of aspect `a` between `s` and `s + 1` either fills
|
||||
/// the width of an `s` cell and leaves `s / a` of it drawn, or the height of
|
||||
/// an `s + 1` cell and leaves `a / (s + 1)`. The two are equal at
|
||||
/// `a = √(s(s+1))`: 2.45 between two and three, 3.46 between three and four.
|
||||
/// The first step is the exception, at 1.9 rather than √2: a 3:2 frame is a
|
||||
/// photograph, not a panorama, and a 2:1 crop a pixel short of 2 still is.
|
||||
pub const SPAN_FROM: [f32; 3] = [1.9, 2.45, 3.46];
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// How many slots a photograph of this aspect would like, before the columns
|
||||
/// have their say: 1, or 2, 3 or 4 — the one lookup both the packing and
|
||||
/// the thumbnail's size class (`ThumbSize::wide`) are chosen from.
|
||||
pub fn natural_span(aspect: Option<f32>) -> usize {
|
||||
let Some(a) = aspect else {
|
||||
return 1;
|
||||
};
|
||||
1 + SPAN_FROM.iter().take_while(|from| a >= **from).count()
|
||||
}
|
||||
|
||||
/// How many slots it gets in a grid of `columns`: its own, or the whole row
|
||||
/// where `full_width` says so or the columns are too few to put it beside
|
||||
/// anything.
|
||||
pub fn span_in(natural: usize, columns: usize, full_width: bool) -> usize {
|
||||
let columns = columns.max(1);
|
||||
if natural <= 1 {
|
||||
1
|
||||
} else if full_width || columns <= natural {
|
||||
columns
|
||||
} else {
|
||||
natural
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's placement of the current view.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Layout {
|
||||
columns: usize,
|
||||
total: usize,
|
||||
/// Each wide photograph: its ordinal, the slot it starts at, how many
|
||||
/// it takes, and how many it would like. Ascending in the first two.
|
||||
anchors: Vec<(usize, usize, usize, usize)>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// Every photograph one slot: the grid as it was.
|
||||
pub fn uniform(columns: usize, total: usize) -> Self {
|
||||
Layout {
|
||||
columns: columns.max(1),
|
||||
total,
|
||||
anchors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack a view of `total` photographs whose wide ones are `wide` —
|
||||
/// `(ordinal, natural span)`, in any order — into rows of `columns`.
|
||||
pub fn pack(columns: usize, total: usize, full_width: bool, wide: &[(usize, usize)]) -> Self {
|
||||
let columns = columns.max(1);
|
||||
let mut wide: Vec<(usize, usize, usize)> = wide
|
||||
.iter()
|
||||
.filter(|(ordinal, _)| *ordinal < total)
|
||||
.map(|&(ordinal, natural)| (ordinal, span_in(natural, columns, full_width), natural))
|
||||
.filter(|(_, span, _)| *span > 1)
|
||||
.collect();
|
||||
wide.sort_unstable();
|
||||
wide.dedup_by_key(|(ordinal, _, _)| *ordinal);
|
||||
|
||||
let mut anchors = Vec::with_capacity(wide.len());
|
||||
// The slot and ordinal just past the last wide cell placed.
|
||||
let (mut next_slot, mut next_ordinal) = (0usize, 0usize);
|
||||
for (ordinal, span, natural) in wide {
|
||||
let mut slot = next_slot + (ordinal - next_ordinal);
|
||||
if slot % columns + span > columns {
|
||||
slot = slot.div_ceil(columns) * columns;
|
||||
}
|
||||
anchors.push((ordinal, slot, span, natural));
|
||||
next_slot = slot + span;
|
||||
next_ordinal = ordinal + 1;
|
||||
}
|
||||
Layout {
|
||||
columns,
|
||||
total,
|
||||
anchors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any photograph takes more than one slot.
|
||||
#[cfg(test)]
|
||||
pub fn is_uniform(&self) -> bool {
|
||||
self.anchors.is_empty()
|
||||
}
|
||||
|
||||
/// The last wide cell at or before `ordinal`.
|
||||
fn anchor_before(&self, ordinal: usize) -> Option<&(usize, usize, usize, usize)> {
|
||||
let at = self.anchors.partition_point(|(o, _, _, _)| *o <= ordinal);
|
||||
at.checked_sub(1).map(|i| &self.anchors[i])
|
||||
}
|
||||
|
||||
/// The slot `ordinal` starts at.
|
||||
pub fn slot_of(&self, ordinal: usize) -> usize {
|
||||
match self.anchor_before(ordinal) {
|
||||
None => ordinal,
|
||||
Some(&(o, slot, _, _)) if o == ordinal => slot,
|
||||
Some(&(o, slot, span, _)) => slot + span + (ordinal - o - 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many slots `ordinal` takes.
|
||||
pub fn span_of(&self, ordinal: usize) -> usize {
|
||||
match self.anchor_before(ordinal) {
|
||||
Some(&(o, _, span, _)) if o == ordinal => span,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The columns' worth of thumbnail `ordinal`'s cell draws: its span,
|
||||
/// but no more than its own class — a 2:1 panorama given the whole row
|
||||
/// on the tablet is fitted to the row's height, and is drawn two
|
||||
/// columns wide in it.
|
||||
pub fn class_span(&self, ordinal: usize) -> usize {
|
||||
match self.anchor_before(ordinal) {
|
||||
Some(&(o, _, span, natural)) if o == ordinal => span.min(natural),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot just past the last photograph: what the scrollbar spans.
|
||||
pub fn total_slots(&self) -> usize {
|
||||
self.slot_of(self.total)
|
||||
}
|
||||
|
||||
/// The first photograph whose cell ends after `slot` — the one a view
|
||||
/// whose first row begins at `slot` shows first. `total` past the end.
|
||||
pub fn ordinal_at(&self, slot: usize) -> usize {
|
||||
// `slot_of(n) + span_of(n)` rises with `n`, so the answer is where
|
||||
// it first passes `slot`.
|
||||
let (mut lo, mut hi) = (0usize, self.total);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self.slot_of(mid) + self.span_of(mid) > slot {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// The photograph a step of `rows` rows from `ordinal` lands on: the
|
||||
/// one under the same column, or where that falls in the gap a wide cell
|
||||
/// left at the end of a row, the last one in that row. Clamped to the
|
||||
/// view.
|
||||
pub fn step_rows(&self, ordinal: usize, rows: isize) -> usize {
|
||||
if self.total == 0 {
|
||||
return 0;
|
||||
}
|
||||
let rows_total = self.total_slots().div_ceil(self.columns) as isize;
|
||||
let from = self.slot_of(ordinal.min(self.total - 1));
|
||||
let row = (from / self.columns) as isize + rows;
|
||||
if row < 0 {
|
||||
return 0;
|
||||
}
|
||||
if row >= rows_total {
|
||||
return self.total - 1;
|
||||
}
|
||||
let target = row as usize * self.columns + from % self.columns;
|
||||
// The last photograph starting at or before the target slot.
|
||||
let after = self.ordinal_starting_after(target);
|
||||
after.saturating_sub(1).min(self.total - 1)
|
||||
}
|
||||
|
||||
/// The first photograph that starts after `slot`.
|
||||
fn ordinal_starting_after(&self, slot: usize) -> usize {
|
||||
let (mut lo, mut hi) = (0usize, self.total);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self.slot_of(mid) > slot {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// Whether the window starting at `offset` agrees with this layout
|
||||
/// about which of its photographs are wide — `natural[i]` being the
|
||||
/// natural span of the window's row `i`, as its own read found it. A
|
||||
/// layout built before a reorder, a merge or a change of aspect does
|
||||
/// not, and is read again.
|
||||
pub fn agrees_with(&self, offset: usize, natural: &[usize], full_width: bool) -> bool {
|
||||
natural
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &n)| self.span_of(offset + i) == span_in(n, self.columns, full_width))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The grid drawn as text, one row per line: `.` a photograph, a letter
|
||||
/// for each slot of a wide one, `_` an empty slot.
|
||||
fn draw(layout: &Layout) -> Vec<String> {
|
||||
let mut slots = vec!['_'; layout.total_slots()];
|
||||
let mut wide = b'A';
|
||||
for n in 0..layout.total {
|
||||
let (s, span) = (layout.slot_of(n), layout.span_of(n));
|
||||
for slot in &mut slots[s..s + span] {
|
||||
assert_eq!(*slot, '_', "photograph {n} overlaps another at slot {s}");
|
||||
*slot = if span > 1 { wide as char } else { '.' };
|
||||
}
|
||||
if span > 1 {
|
||||
wide += 1;
|
||||
}
|
||||
}
|
||||
slots
|
||||
.chunks(layout.columns)
|
||||
.map(|r| r.iter().collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_of_ordinary_photographs_is_the_grid_it_always_was() {
|
||||
let l = Layout::pack(4, 10, false, &[]);
|
||||
assert!(l.is_uniform());
|
||||
assert_eq!(l, Layout::uniform(4, 10));
|
||||
assert_eq!(draw(&l), ["....", "....", ".."]);
|
||||
for n in 0..10 {
|
||||
assert_eq!(l.slot_of(n), n);
|
||||
}
|
||||
assert_eq!(l.ordinal_at(8), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_photograph_takes_two_slots_and_the_rest_move_along() {
|
||||
// Photograph 2 is a 2:1 panorama, 5 a 4:1.
|
||||
let l = Layout::pack(5, 10, false, &[(2, 2), (5, 3)]);
|
||||
assert_eq!(draw(&l), ["..AA.", ".BBB.", "..."]);
|
||||
assert_eq!(l.slot_of(3), 4);
|
||||
assert_eq!(l.slot_of(5), 6);
|
||||
assert_eq!(l.span_of(5), 3);
|
||||
assert_eq!(l.total_slots(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_photograph_that_does_not_fit_starts_the_next_row() {
|
||||
// Three ordinary frames fill three of four columns; the panorama
|
||||
// after them needs two, so it opens the next row and the fourth
|
||||
// column of the first stays empty. Nothing later is moved up into
|
||||
// it: the grid still reads in capture order.
|
||||
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
||||
assert_eq!(draw(&l), ["..._", "AAA.", "..."]);
|
||||
assert_eq!(l.slot_of(3), 4);
|
||||
assert_eq!(l.slot_of(4), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_too_few_columns_a_wide_photograph_takes_the_whole_row() {
|
||||
let l = Layout::pack(3, 5, false, &[(1, 3)]);
|
||||
assert_eq!(draw(&l), [".__", "AAA", "..."]);
|
||||
let l = Layout::pack(2, 4, false, &[(1, 2)]);
|
||||
assert_eq!(draw(&l), ["._", "AA", ".."]);
|
||||
// One column: nothing to span.
|
||||
let l = Layout::pack(1, 3, false, &[(1, 3)]);
|
||||
assert!(l.is_uniform());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_the_tablet_a_wide_photograph_takes_the_whole_row() {
|
||||
let l = Layout::pack(5, 8, true, &[(2, 2)]);
|
||||
assert_eq!(draw(&l), ["..___", "AAAAA", "....."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_each_class_boundary_the_cell_left_empty_is_the_smaller() {
|
||||
// At `a = √(s(s+1))` an `s` cell and an `s + 1` one leave the same
|
||||
// share undrawn; either side, the class chosen leaves less.
|
||||
let drawn = |a: f32, s: usize| (s as f32 / a).min(a / s as f32);
|
||||
for (k, from) in SPAN_FROM.iter().enumerate().skip(1) {
|
||||
let s = k + 1;
|
||||
for a in [from - 0.05, from + 0.05] {
|
||||
let chosen = natural_span(Some(a));
|
||||
let other = if chosen == s { s + 1 } else { s };
|
||||
assert!(
|
||||
drawn(a, chosen) >= drawn(a, other),
|
||||
"{a}: {chosen} draws {} and {other} {}",
|
||||
drawn(a, chosen),
|
||||
drawn(a, other)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_of_every_class_packs_in_reading_order() {
|
||||
// Six columns: a 2:1, a frame, a 3:1, then a 4:1 that does not fit
|
||||
// beside them, a frame, a 5:1 that is still four, and a 2:1 beside
|
||||
// it.
|
||||
let aspects = [2.0, 1.5, 3.0, 4.0, 1.5, 5.0, 2.0, 1.5];
|
||||
let wide: Vec<(usize, usize)> = aspects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, a)| (n, natural_span(Some(*a))))
|
||||
.filter(|(_, s)| *s > 1)
|
||||
.collect();
|
||||
let l = Layout::pack(6, aspects.len(), false, &wide);
|
||||
assert_eq!(draw(&l), ["AA.BBB", "CCCC._", "DDDDEE", "."]);
|
||||
assert_eq!(l.class_span(3), 4);
|
||||
assert_eq!(l.class_span(5), 4);
|
||||
// With four columns the 4:1s take whole rows and nothing else moves
|
||||
// out of order.
|
||||
let l = Layout::pack(4, aspects.len(), false, &wide);
|
||||
assert_eq!(draw(&l), ["AA._", "BBB_", "CCCC", ".___", "DDDD", "EE."]);
|
||||
// On the tablet every wide one takes the row, and its thumbnail is
|
||||
// still its own class.
|
||||
let l = Layout::pack(6, aspects.len(), true, &wide);
|
||||
assert_eq!(l.span_of(0), 6);
|
||||
assert_eq!(l.class_span(0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_photographs_side_by_side_and_back_to_back() {
|
||||
let l = Layout::pack(5, 7, false, &[(0, 2), (1, 2), (2, 2), (3, 3)]);
|
||||
assert_eq!(draw(&l), ["AABB_", "CCDDD", "..."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_visible_slot_names_the_photograph_there() {
|
||||
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
||||
// Row 1 begins at slot 4, the panorama.
|
||||
assert_eq!(l.ordinal_at(4), 3);
|
||||
// The empty slot at the end of row 0 belongs to nothing: the next
|
||||
// photograph is the panorama.
|
||||
assert_eq!(l.ordinal_at(3), 3);
|
||||
// Slot 5 is inside the panorama.
|
||||
assert_eq!(l.ordinal_at(5), 3);
|
||||
assert_eq!(l.ordinal_at(8), 5);
|
||||
// Past the end.
|
||||
assert_eq!(l.ordinal_at(40), 8);
|
||||
// And every ordinal is found again at its own slot.
|
||||
for n in 0..8 {
|
||||
assert_eq!(l.ordinal_at(l.slot_of(n)), n);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_move_by_rows_not_by_a_row_of_ordinals() {
|
||||
// ..._
|
||||
// AAA.
|
||||
// ....
|
||||
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
||||
// Down from the second frame lands in the panorama under it.
|
||||
assert_eq!(l.step_rows(1, 1), 3);
|
||||
// Down from the panorama, to the frame under its first column.
|
||||
assert_eq!(l.step_rows(3, 1), 5);
|
||||
// Up from the frame beside the panorama: the gap above it is empty,
|
||||
// so the last frame of that row.
|
||||
assert_eq!(l.step_rows(4, -1), 2);
|
||||
// Up from under the panorama's middle, into the panorama.
|
||||
assert_eq!(l.step_rows(6, -1), 3);
|
||||
// Clamped at both ends.
|
||||
assert_eq!(l.step_rows(1, -3), 0);
|
||||
assert_eq!(l.step_rows(1, 9), 7);
|
||||
// A page is several rows at once.
|
||||
assert_eq!(l.step_rows(0, 2), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_that_disagrees_with_the_layout_is_caught() {
|
||||
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
||||
assert!(l.agrees_with(2, &[1, 3, 1], false));
|
||||
// The panorama has moved to ordinal 4: the window sees it there.
|
||||
assert!(!l.agrees_with(2, &[1, 1, 3], false));
|
||||
// A photograph that became wide.
|
||||
assert!(!l.agrees_with(0, &[2], false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_by_aspect() {
|
||||
assert_eq!(natural_span(None), 1);
|
||||
assert_eq!(natural_span(Some(1.5)), 1);
|
||||
// The class boundaries, each side.
|
||||
assert_eq!(natural_span(Some(1.89)), 1);
|
||||
assert_eq!(natural_span(Some(1.9)), 2);
|
||||
assert_eq!(natural_span(Some(2.44)), 2);
|
||||
assert_eq!(natural_span(Some(2.45)), 3);
|
||||
assert_eq!(natural_span(Some(3.45)), 3);
|
||||
assert_eq!(natural_span(Some(3.46)), 4);
|
||||
assert_eq!(natural_span(Some(9.0)), 4, "four is the widest");
|
||||
assert_eq!(span_in(2, 6, false), 2);
|
||||
assert_eq!(span_in(3, 3, false), 3);
|
||||
assert_eq!(span_in(3, 2, false), 2);
|
||||
assert_eq!(span_in(2, 6, true), 6);
|
||||
assert_eq!(span_in(1, 6, true), 1);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
mod controller;
|
||||
mod filter_bar;
|
||||
mod grid;
|
||||
pub(crate) mod layout;
|
||||
mod offline;
|
||||
mod open;
|
||||
mod ratings_keywords;
|
||||
@@ -48,8 +49,8 @@ mod window;
|
||||
|
||||
pub use controller::LibraryController;
|
||||
pub use grid::wire;
|
||||
pub use open::{catalogue_composite, open, reload};
|
||||
pub(crate) use open::{forget_catalog, show_catalog_now, start_rescan};
|
||||
pub use open::{open, reload};
|
||||
pub use ratings_keywords::paste_settings_to_selection;
|
||||
pub(crate) use ratings_keywords::{save_judgements, start_sidecar_writes};
|
||||
pub use timeline::format_date;
|
||||
|
||||
@@ -26,7 +26,7 @@ use super::offline::refresh_offline;
|
||||
use super::ratings_keywords::refresh_xmp_conflicts;
|
||||
use super::sync::{start_derived_sync, start_sweep};
|
||||
use super::timeline::{apply_place, note_place};
|
||||
use super::window::{load_window, window_start};
|
||||
use super::window::{load_window, seek_to, window_start};
|
||||
|
||||
/// Reload the grid for the current scope and offset.
|
||||
///
|
||||
@@ -41,6 +41,66 @@ pub fn reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
note_place(window, ctl);
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Put a composite the merge has just staged into the catalog and the grid,
|
||||
/// before its upload has finished and before any scan could find it.
|
||||
///
|
||||
/// See `library::composite` for why this does not wait for the scan. The
|
||||
/// row is keyed where the scan will list the file, so the scan after the
|
||||
/// upload updates it rather than adding a second.
|
||||
pub fn catalogue_composite(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
placed: &crate::merge_ui::Placed,
|
||||
) {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let root = conn.account.root.clone();
|
||||
let c = &placed.composite;
|
||||
let source_ref = crate::export::staged_remote_path(&root, &placed.remote_dir, &c.name);
|
||||
let row = library::CompositeRow {
|
||||
source_ref: source_ref.as_str().to_string(),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
captured_at: c.captured_at,
|
||||
captured_offset: c.captured_offset,
|
||||
camera: c.camera.clone(),
|
||||
lens: c.lens.clone(),
|
||||
iso: c.iso,
|
||||
file_size: c.file_size,
|
||||
sources: placed.sources.clone(),
|
||||
};
|
||||
let written = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
library::catalogue_composite(catalog, &root, &row)
|
||||
};
|
||||
match written {
|
||||
Ok(image) => {
|
||||
log::info!("merge: {} catalogued as image {image}", row.source_ref);
|
||||
let decoded: Vec<(dr_thumbs::ThumbSize, slint::Image)> = c
|
||||
.thumbnails
|
||||
.iter()
|
||||
.filter_map(|(class, t)| {
|
||||
let (w, h, rgba) = dr_thumbs::decode_rgba(&t.bytes).ok()?;
|
||||
Some((*class, super::window::to_slint_image(w, h, &rgba)))
|
||||
})
|
||||
.collect();
|
||||
if !decoded.is_empty() {
|
||||
ctl.merged_thumbs.borrow_mut().insert(image, decoded);
|
||||
}
|
||||
// Asked for afresh: a row under this name may have been drawn,
|
||||
// and answered, before this merge wrote over it.
|
||||
ctl.requested.borrow_mut().retain(|(id, _)| *id != image);
|
||||
reload(window, ctl);
|
||||
}
|
||||
Err(e) => log::warn!("merge: cataloguing {}: {e}", row.source_ref),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a library: show the grid, start a scan, then fill in thumbnails.
|
||||
///
|
||||
/// Called from the launch screen's "Open library" button — the callback that
|
||||
@@ -68,6 +128,9 @@ pub fn open(
|
||||
|
||||
let filter = account.format_filter();
|
||||
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
|
||||
// Image ids are one catalog's; another library's would name other
|
||||
// photographs.
|
||||
ctl.merged_thumbs.borrow_mut().clear();
|
||||
|
||||
// TRACES: FR-UI-8
|
||||
// Where this library's position is kept, and what this device signs it
|
||||
@@ -713,9 +776,7 @@ pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let total = w.global::<Library>().get_library_total().max(0) as usize;
|
||||
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
|
||||
load_window(&w, &ctl_cb);
|
||||
w.global::<Library>().set_library_scroll_to(anchor as i32);
|
||||
w.global::<Library>()
|
||||
.set_library_scroll_token(w.global::<Library>().get_library_scroll_token() + 1);
|
||||
seek_to(&w, &ctl_cb, anchor);
|
||||
});
|
||||
|
||||
// Replacing the slot drops the previous timer, which is what makes this
|
||||
|
||||
@@ -112,23 +112,7 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
|
||||
let outbox = crate::export::outbox_dir(&conn.account);
|
||||
if crate::export::pending_count(&outbox) > 0 {
|
||||
let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox);
|
||||
executors::spawn(Executor::Io, "upload-log", move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
match msg {
|
||||
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
||||
crate::export::UploadMessage::Finished {
|
||||
uploaded,
|
||||
remaining,
|
||||
error,
|
||||
} => {
|
||||
log::info!("export: {uploaded} uploaded, {remaining} still queued");
|
||||
if let Some(e) = error {
|
||||
log::warn!("export upload stopped: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::export::watch_upload(rx, window.as_weak());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{AppWindow, Library, TimelineBar, View};
|
||||
|
||||
use super::controller::LibraryController;
|
||||
use super::filter_bar::{push_people_chips, refilter};
|
||||
use super::window::{load_window, mark_open, window_start};
|
||||
use super::window::{load_window, mark_open, seek_to, show_cursor, window_start};
|
||||
|
||||
/// Move the timeline's zoom by whole levels.
|
||||
///
|
||||
@@ -501,13 +501,10 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
||||
// Move the viewport as well as the window. Cells are drawn at their
|
||||
// absolute place in the library, so loading rows around image 15,000 while
|
||||
// the viewport sits at row 0 shows an empty grid until the user scrolls.
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_to(position as i32);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||||
load_window(window, ctl);
|
||||
// After the load, which is what places the cells: the grid scrolls by
|
||||
// slot, and a panorama before `position` moves its slot along.
|
||||
seek_to(window, ctl, position);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7
|
||||
@@ -543,12 +540,7 @@ pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// `resume_at` is what the grid last reported as its first visible image,
|
||||
// which is the thing the photographer is actually looking at.
|
||||
let anchor = ctl.resume_at.get().min(total - 1);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_to(anchor as i32);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||||
seek_to(window, ctl, anchor);
|
||||
}
|
||||
|
||||
/// Format a bucket start for the histogram's hover label.
|
||||
@@ -998,16 +990,11 @@ pub(super) fn resume_position(
|
||||
// Before the grid is shown, not after: the markup gates it on an `if`, and
|
||||
// the rebuilt Flickable reads `scroll-to` in its `init`. Setting these
|
||||
// afterwards would leave that init to run against the previous position.
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_to(anchor as i32);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||||
seek_to(window, ctl, anchor);
|
||||
|
||||
if let Some(at) = focus {
|
||||
coll.set_cursor(Some(at));
|
||||
window.global::<Library>().set_library_cursor(at as i32);
|
||||
show_cursor(window, ctl, at);
|
||||
}
|
||||
|
||||
// The view has moved, so the capture-time marker has to move with it.
|
||||
|
||||
@@ -230,6 +230,31 @@ pub(super) fn bring_window_to(
|
||||
.filter(|row| *row < ctl.paths.borrow().len())
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Ask the grid to show ordinal `at` in its first row.
|
||||
///
|
||||
/// The grid scrolls by slots, which a panorama's cell makes different from
|
||||
/// ordinals; this is the one conversion, so no caller spells `scroll-to`
|
||||
/// in the wrong unit.
|
||||
pub(super) fn seek_to(window: &AppWindow, ctl: &LibraryController, at: usize) {
|
||||
let slot = ctl.layout.borrow().slot_of(at);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_to(slot as i32);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||||
}
|
||||
|
||||
/// Put the keyboard cursor on ordinal `at`, telling the grid where its cell
|
||||
/// is so its row can be kept in view.
|
||||
pub(super) fn show_cursor(window: &AppWindow, ctl: &LibraryController, at: usize) {
|
||||
window.global::<Library>().set_library_cursor(at as i32);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_cursor_slot(ctl.layout.borrow().slot_of(at) as i32);
|
||||
}
|
||||
|
||||
/// Record that the photograph at `row` of the loaded window is the one now
|
||||
/// open in develop, and mark it on the roll.
|
||||
pub(super) fn mark_open(window: &AppWindow, ctl: &LibraryController, row: usize) {
|
||||
@@ -327,6 +352,10 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
};
|
||||
let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
|
||||
*ctl.library_facts.borrow_mut() = Some(facts);
|
||||
// A different list has its panoramas in different places.
|
||||
if describes_something_new {
|
||||
*ctl.wide.borrow_mut() = None;
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
// Whether the newly scoped collection is already pinned. Read here rather
|
||||
@@ -444,18 +473,37 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
.into(),
|
||||
);
|
||||
|
||||
// TRACES: FR-MRG-6
|
||||
// Where each cell goes: the layout of the whole list, checked against
|
||||
// what this window says about its own photographs' shapes and read again
|
||||
// if they disagree — a reorder, a merge, a composite's size learned.
|
||||
let columns = window.global::<Library>().get_library_columns().max(1) as usize;
|
||||
let layout = place_cells(
|
||||
catalog, ctl, &cells, offset, total, columns, scope, &filter, trash,
|
||||
);
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_total_slots(layout.total_slots() as i32);
|
||||
let cursor = window.global::<Library>().get_library_cursor();
|
||||
if cursor >= 0 {
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_cursor_slot(layout.slot_of(cursor as usize) as i32);
|
||||
}
|
||||
|
||||
// Month headings. The grid is ordered by capture time, so without these a
|
||||
// wall of thumbnails gives no sense of *when* you are looking — the
|
||||
// sidebar says it, but only if you consult it.
|
||||
let columns = window.global::<Library>().get_library_columns().max(1) as usize;
|
||||
let headings = period_headings(
|
||||
cells.iter().map(|c| {
|
||||
c.captured_at.map(|t| {
|
||||
let (y, m, _, _) = civil_from_unix(t);
|
||||
(y, m)
|
||||
})
|
||||
cells.iter().enumerate().map(|(i, c)| {
|
||||
(
|
||||
c.captured_at.map(|t| {
|
||||
let (y, m, _, _) = civil_from_unix(t);
|
||||
(y, m)
|
||||
}),
|
||||
layout.slot_of(offset + i),
|
||||
)
|
||||
}),
|
||||
offset,
|
||||
columns,
|
||||
);
|
||||
|
||||
@@ -467,12 +515,51 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
hold_thumbnails(&previous, &ids, &classes)
|
||||
};
|
||||
|
||||
// A composite a merge has just catalogued is drawn from the merge's own
|
||||
// thumbnails until the store can serve it (FR-MRG-6), and counts as
|
||||
// served at that class so no fetch is spent on a file still uploading.
|
||||
let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
|
||||
let mut held = held;
|
||||
{
|
||||
let merged = ctl.merged_thumbs.borrow();
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
let Some(made) = merged.get(&c.image_id) else {
|
||||
continue;
|
||||
};
|
||||
let wanted = dr_thumbs::ThumbSize::for_span(layout.class_span(offset + i), cell_pixels);
|
||||
if held
|
||||
.get(&c.image_id)
|
||||
.is_some_and(|h| h.class == Some(wanted))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let pick = made
|
||||
.iter()
|
||||
.find(|(class, _)| *class == wanted)
|
||||
.or_else(|| made.iter().max_by_key(|(class, _)| class.edge()));
|
||||
if let Some((class, image)) = pick {
|
||||
held.insert(
|
||||
c.image_id,
|
||||
Held {
|
||||
thumbnail: image.clone(),
|
||||
has_thumb: true,
|
||||
unavailable: false,
|
||||
class: Some(*class),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rows: Vec<LibraryCell> = cells
|
||||
.iter()
|
||||
.zip(headings)
|
||||
.map(|(c, heading)| {
|
||||
.enumerate()
|
||||
.map(|(i, (c, heading))| {
|
||||
let carried = held.get(&c.image_id);
|
||||
LibraryCell {
|
||||
slot: layout.slot_of(offset + i) as i32,
|
||||
span: layout.span_of(offset + i) as i32,
|
||||
period_heading: heading.into(),
|
||||
// A freshly loaded window has no drag in flight.
|
||||
lifted: false,
|
||||
@@ -574,6 +661,55 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
request_thumbnails(window, ctl);
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// The layout of the current list, made to agree with the window just read.
|
||||
///
|
||||
/// The panoramas' ordinals are kept between loads — a scroll does not move
|
||||
/// them — and read again only when they are unknown or when this window
|
||||
/// finds a photograph wide that the layout does not, or the other way round.
|
||||
/// That check is over the window's own rows, in memory; the read it can
|
||||
/// trigger is one query ([`library::wide_ordinals`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place_cells(
|
||||
catalog: &dr_catalog::Catalog,
|
||||
ctl: &LibraryController,
|
||||
cells: &[library::LibraryCell],
|
||||
offset: usize,
|
||||
total: usize,
|
||||
columns: usize,
|
||||
scope: Option<library::Scope>,
|
||||
filter: &library::RatingFilter,
|
||||
trash: bool,
|
||||
) -> super::layout::Layout {
|
||||
use super::layout::{natural_span, Layout};
|
||||
let full_width = dr_plat::is_touch_first();
|
||||
let natural: Vec<usize> = cells.iter().map(|c| natural_span(c.aspect)).collect();
|
||||
let read = || -> Vec<(usize, usize)> {
|
||||
match library::wide_ordinals(catalog, scope, filter, trash) {
|
||||
Ok(wide) => wide
|
||||
.into_iter()
|
||||
.map(|(ordinal, aspect)| (ordinal, natural_span(Some(aspect))))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
log::warn!("reading the grid's panoramas: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
if ctl.wide.borrow().is_none() {
|
||||
*ctl.wide.borrow_mut() = Some(read());
|
||||
}
|
||||
let pack = |wide: &[(usize, usize)]| Layout::pack(columns, total, full_width, wide);
|
||||
let mut layout = pack(ctl.wide.borrow().as_deref().unwrap_or_default());
|
||||
if !layout.agrees_with(offset, &natural, full_width) {
|
||||
let wide = read();
|
||||
layout = pack(&wide);
|
||||
*ctl.wide.borrow_mut() = Some(wide);
|
||||
}
|
||||
*ctl.layout.borrow_mut() = layout.clone();
|
||||
layout
|
||||
}
|
||||
|
||||
/// Where a row of the loaded window sits in the fetch queue.
|
||||
///
|
||||
/// On screen first, top to bottom; then the rows below the view, nearest
|
||||
@@ -611,11 +747,17 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
return;
|
||||
};
|
||||
|
||||
// The drawn cell size decides which class to ask for. Chosen once for the
|
||||
// batch rather than per row, and carried through to the drain so a cell it
|
||||
// fills can record what it is now showing.
|
||||
// The drawn cell size decides which class to ask for, and a panorama's
|
||||
// cell asks for the wide one (FR-MRG-6). Carried through to the drain per
|
||||
// row, so a cell it fills can record what it is now showing.
|
||||
let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
|
||||
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
|
||||
let offset = *ctl.offset.borrow();
|
||||
let classes: Vec<dr_thumbs::ThumbSize> = {
|
||||
let layout = ctl.layout.borrow();
|
||||
(0..ctl.paths.borrow().len())
|
||||
.map(|i| dr_thumbs::ThumbSize::for_span(layout.class_span(offset + i), cell_pixels))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut wanted: Vec<library::ThumbnailRequest> = {
|
||||
let paths = ctl.paths.borrow();
|
||||
@@ -631,7 +773,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let image_id = *image_ids.get(i)?;
|
||||
// A zoomed grid asks for detail a 256px thumbnail cannot give,
|
||||
// and a wall of small cells does not pay for it.
|
||||
let thumb_size = class;
|
||||
let thumb_size = classes
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(dr_thumbs::ThumbSize::Grid);
|
||||
// Keyed on the photograph, so scrolling back over a cell that
|
||||
// has already been served does not ask for it again.
|
||||
if !requested.insert((image_id, thumb_size)) {
|
||||
@@ -660,7 +805,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// the model they were built against; only the order they are asked for in
|
||||
// changes.
|
||||
{
|
||||
let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow());
|
||||
let first_on_screen = ctl.resume_at.get().saturating_sub(offset);
|
||||
let on_screen = ctl.viewport_cells.get().max(1);
|
||||
wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen));
|
||||
}
|
||||
@@ -672,7 +817,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
library::thumbs_dir(&conn.account),
|
||||
library::catalog_path(&conn.account),
|
||||
);
|
||||
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
|
||||
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, classes);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9 | FR-DEV-6
|
||||
@@ -798,7 +943,7 @@ fn drain_thumbnails(
|
||||
ctl: Rc<LibraryController>,
|
||||
rx: Receiver<ThumbnailMessage>,
|
||||
requested: usize,
|
||||
class: dr_thumbs::ThumbSize,
|
||||
classes: Vec<dr_thumbs::ThumbSize>,
|
||||
) {
|
||||
let timer = slint::Timer::default();
|
||||
let ctl_cb = ctl.clone();
|
||||
@@ -940,7 +1085,9 @@ fn drain_thumbnails(
|
||||
model.set_row_data(t.row, row);
|
||||
// What this cell is now showing, so the next reload
|
||||
// can carry it over and know not to ask again.
|
||||
record_class(&ctl_cb, t.row, class);
|
||||
if let Some(class) = classes.get(t.row) {
|
||||
record_class(&ctl_cb, t.row, *class);
|
||||
}
|
||||
}
|
||||
}
|
||||
ThumbnailMessage::Unavailable { row, reason } => {
|
||||
@@ -952,7 +1099,9 @@ fn drain_thumbnails(
|
||||
// A verdict is worth carrying too: "no preview" is
|
||||
// an answer about the file, and re-asking it on
|
||||
// every scroll is a fetch that will fail again.
|
||||
record_class(&ctl_cb, row, class);
|
||||
if let Some(class) = classes.get(row) {
|
||||
record_class(&ctl_cb, row, *class);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TRACES: FR-CAT-9
|
||||
@@ -989,7 +1138,7 @@ fn drain_thumbnails(
|
||||
/// This is a CPU copy, which is acceptable here and not in the develop path:
|
||||
/// a 256px thumbnail is 256 KB and happens once per image, where the canvas
|
||||
/// would pay per frame (ARCH §6.1).
|
||||
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||||
pub(super) fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||||
let expected = (width as usize) * (height as usize) * 4;
|
||||
let src = &rgba[..expected.min(rgba.len())];
|
||||
|
||||
+265
-23
@@ -42,7 +42,7 @@ use dr_gpu::{
|
||||
};
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{self, Projection};
|
||||
use dr_pano::{Alignment, Gray};
|
||||
use dr_pano::{Alignment, Gray, SeamMap};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
pub use crate::export::Cancel;
|
||||
@@ -87,6 +87,11 @@ pub enum MergeDestination {
|
||||
outbox: PathBuf,
|
||||
/// The sources' folder, relative to the library root.
|
||||
remote_dir: String,
|
||||
/// The names the library already holds in that folder, as the
|
||||
/// catalog has them. The upload replaces whatever is at its name, so
|
||||
/// a second merge of the same frames must not be called what the
|
||||
/// first one was.
|
||||
taken: std::collections::HashSet<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -98,8 +103,14 @@ pub struct MergeRequest {
|
||||
pub destination: MergeDestination,
|
||||
/// `None` for the projection the field of view suggests.
|
||||
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,
|
||||
/// 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.
|
||||
pub chunk: (u32, u32),
|
||||
/// The border filler's weights, if the device has them (FR-MRG-4).
|
||||
@@ -117,6 +128,8 @@ impl MergeRequest {
|
||||
destination,
|
||||
projection: None,
|
||||
feather_px: 200.0,
|
||||
seams: true,
|
||||
seam_blend_px: 64.0,
|
||||
chunk: (2048, 512),
|
||||
inpaint_model: None,
|
||||
decoder: dr_decode::default(),
|
||||
@@ -228,18 +241,45 @@ pub enum MergeEvent {
|
||||
/// for the photographer to confirm, and what names a failure.
|
||||
Aligned(AlignmentReport),
|
||||
/// The composite is written: on the device at `path`, or staged in the
|
||||
/// outbox for the drain to upload, in which case `staged` is true and
|
||||
/// the library learns of it when the folder is next scanned.
|
||||
/// outbox for the drain to upload, in which case `staged` is true.
|
||||
/// `composite` is what the library needs to show it before the upload
|
||||
/// has finished and a scan has found it (FR-MRG-6).
|
||||
Done {
|
||||
path: PathBuf,
|
||||
staged: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
composite: Box<Composite>,
|
||||
},
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// A finished composite, described for the catalog: everything a scan and a
|
||||
/// header read would have found, known here without either.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Composite {
|
||||
/// Its name where it goes — in the library folder for a staged merge.
|
||||
pub name: String,
|
||||
/// The picture it opens on: the crop where the border was cropped, the
|
||||
/// whole composite where it was filled.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The middle of the sweep, as written into the DNG; `None` where no
|
||||
/// frame carried a time.
|
||||
pub captured_at: Option<i64>,
|
||||
pub captured_offset: Option<i32>,
|
||||
pub camera: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
pub iso: Option<u32>,
|
||||
pub file_size: u64,
|
||||
/// Made from the final image as develop would first show it
|
||||
/// ([`crate::merge_thumbs`]); empty if that failed, and the grid then
|
||||
/// thumbnails it the ordinary way.
|
||||
pub thumbnails: Vec<(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)>,
|
||||
}
|
||||
|
||||
impl MergeEvent {
|
||||
/// Whether this is the job's last word: after one of these the worker
|
||||
/// has nothing more to say, so its channel closing is expected.
|
||||
@@ -446,11 +486,20 @@ fn run_inner(
|
||||
let preview = match s.alignment.is_complete() {
|
||||
false => None,
|
||||
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(
|
||||
&colour,
|
||||
&proxies,
|
||||
&s.alignment,
|
||||
&s.gains,
|
||||
blend,
|
||||
projection,
|
||||
first_raw,
|
||||
1600,
|
||||
@@ -573,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
|
||||
// indexes them.
|
||||
let Solved {
|
||||
@@ -598,10 +654,14 @@ fn run_inner(
|
||||
// 5. Merge, into a DNG beside the first frame.
|
||||
let name = format!("{}-pano.dng", stem(Path::new(&first_name)));
|
||||
let (out_path, staged) = match &request.destination {
|
||||
MergeDestination::Local(dir) => (unused_name(dir, &name), false),
|
||||
MergeDestination::Outbox { outbox, remote_dir } => {
|
||||
MergeDestination::Local(dir) => (unused_name(dir, &name, &Default::default()), false),
|
||||
MergeDestination::Outbox {
|
||||
outbox,
|
||||
remote_dir,
|
||||
taken,
|
||||
} => {
|
||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||
let path = unused_name(outbox, &name);
|
||||
let path = unused_name(outbox, &name, taken);
|
||||
// The record first here, unlike an export: the payload is
|
||||
// written over minutes and a record naming a half-written file
|
||||
// is worse than a payload with no record, so the record is
|
||||
@@ -617,10 +677,20 @@ fn run_inner(
|
||||
(path, true)
|
||||
}
|
||||
};
|
||||
// Written under a name of its own and renamed into place once the last
|
||||
// strip is in. The record above names `out_path`, and a drain that runs
|
||||
// while the merge is still writing — a sync pass fires one whenever the
|
||||
// sweep finishes — takes whatever is at that name: it uploaded the first
|
||||
// few hundred megabytes of a composite, cleared the record, and the file
|
||||
// on the server stayed truncated for good. `pending` skips a record whose
|
||||
// payload does not exist yet, so the rename is what releases it.
|
||||
let part_path = part_name(&out_path);
|
||||
let cleanup = |path: &Path| {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(part_name(path));
|
||||
if staged {
|
||||
let _ = std::fs::remove_file(crate::export::destination_record(path));
|
||||
let _ = std::fs::remove_file(crate::export::thumbnails_record(path));
|
||||
}
|
||||
};
|
||||
let (out_w, out_h) = (report_size(&bounds).0, report_size(&bounds).1);
|
||||
@@ -630,6 +700,8 @@ fn run_inner(
|
||||
let profile = dng_profile(first, white_level);
|
||||
let balance = profile.as_shot_neutral.map(|n| 1.0 / n.max(1e-3));
|
||||
let (_, mut carried) = crate::export::header_for_file(&first.meta);
|
||||
// The thumbnails' copy of the profile; the writer takes the original.
|
||||
let thumb_profile = profile.clone();
|
||||
// The composite is dated at the middle of its sweep — the mean of the
|
||||
// frames' capture times — so it sorts among the frames it was made
|
||||
// from, not at the first of them and not at the moment of the merge.
|
||||
@@ -640,11 +712,24 @@ fn run_inner(
|
||||
carried.captured_at = Some((sum / stamps.len() as i128) as i64);
|
||||
}
|
||||
|
||||
// What the catalog is told about the file (FR-MRG-6), taken before the
|
||||
// header moves into the writer.
|
||||
let thumb_header = carried.clone();
|
||||
let described = (
|
||||
carried.captured_at,
|
||||
carried.captured_offset,
|
||||
crate::library::camera_label(carried.make.as_deref(), carried.model.as_deref()),
|
||||
carried.lens.as_ref().map(|l| l.trim().to_string()),
|
||||
carried.iso,
|
||||
);
|
||||
|
||||
let output = MergeOutput {
|
||||
projection,
|
||||
scale: focal_full,
|
||||
bounds,
|
||||
feather: request.feather_px,
|
||||
seams: seam_map.clone(),
|
||||
seam_blend: request.seam_blend_px,
|
||||
chunk: request.chunk,
|
||||
sample_scale: white_level as f32,
|
||||
balance,
|
||||
@@ -687,6 +772,8 @@ fn run_inner(
|
||||
max_v: bounds.max_v / q,
|
||||
},
|
||||
feather: request.feather_px,
|
||||
seams: seam_map.clone(),
|
||||
seam_blend: request.seam_blend_px / q as f32,
|
||||
chunk: request.chunk,
|
||||
sample_scale: white_level as f32,
|
||||
balance,
|
||||
@@ -798,7 +885,7 @@ fn run_inner(
|
||||
let inscribed_for_writer = inscribed.clone();
|
||||
let fill_for_writer = fill_cam.is_some();
|
||||
let file =
|
||||
std::fs::File::create(&out_path).map_err(|e| format!("{}: {e}", out_path.display()))?;
|
||||
std::fs::File::create(&part_path).map_err(|e| format!("{}: {e}", part_path.display()))?;
|
||||
let writer = executors::spawn(Executor::Io, "dng-write", move || -> Result<(), String> {
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
dr_export::write_linear_dng(
|
||||
@@ -830,6 +917,9 @@ fn run_inner(
|
||||
|
||||
let total_bands = out_h.div_ceil(rows_per_strip) as usize;
|
||||
let mut bands_done = 0usize;
|
||||
// The final image, reduced as it goes by, for the thumbnails (FR-MRG-6).
|
||||
let mut reduced =
|
||||
crate::merge_thumbs::Reduced::new(out_w, out_h, crate::merge_thumbs::SOURCE_EDGE);
|
||||
progress("Merging", 0, total_bands);
|
||||
let merged = pass.merge(
|
||||
&mut adjust,
|
||||
@@ -884,6 +974,7 @@ fn run_inner(
|
||||
}
|
||||
}
|
||||
}
|
||||
reduced.push(band.first_row, band.rows, &rgb);
|
||||
band_tx
|
||||
.send(rgb)
|
||||
.map_err(|_| dr_gpu::GpuError::Readback("the writer stopped".into()))
|
||||
@@ -910,6 +1001,52 @@ fn run_inner(
|
||||
cleanup(&out_path);
|
||||
return Err(e);
|
||||
}
|
||||
// The rectangle the file opens on, as the writer recorded it.
|
||||
let picture = if fill_cam.is_some() {
|
||||
None
|
||||
} else {
|
||||
inscribed
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|i| clamp_crop(i.best(), out_w, out_h))
|
||||
.filter(|r| r.width > 0 && r.height > 0)
|
||||
};
|
||||
|
||||
// TRACES: FR-MRG-6
|
||||
// The thumbnails, from the final image, developed as develop will first
|
||||
// show the file. Before the rename, and staged beside the payload before
|
||||
// it: the rename is what lets a drain take the file, and the drain is
|
||||
// what puts these in the store once the server has named it.
|
||||
// The merge's GPU memory back before develop asks for its own.
|
||||
drop(resident);
|
||||
drop(pass);
|
||||
progress("Making thumbnails", 0, 1);
|
||||
let (pw, ph) = picture.map_or((out_w, out_h), |r| (r.width, r.height));
|
||||
let thumbnails = match crate::merge_thumbs::render(
|
||||
ctx,
|
||||
reduced,
|
||||
&thumb_profile,
|
||||
&thumb_header,
|
||||
picture,
|
||||
request.decoder,
|
||||
&crate::merge_thumbs::classes(pw, ph),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
log::warn!("merge: the thumbnails could not be made: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if staged && !thumbnails.is_empty() {
|
||||
if let Err(e) = crate::export::write_thumbnails(&out_path, &thumbnails) {
|
||||
log::warn!("merge: the thumbnails could not be staged: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::rename(&part_path, &out_path) {
|
||||
cleanup(&out_path);
|
||||
return Err(format!("{}: {e}", out_path.display()));
|
||||
}
|
||||
log::info!(
|
||||
"merge: {}×{} written to {} in {:?}",
|
||||
out_w,
|
||||
@@ -958,14 +1095,44 @@ fn run_inner(
|
||||
}
|
||||
}
|
||||
|
||||
let (captured_at, captured_offset, camera, lens, iso) = described;
|
||||
let composite = Composite {
|
||||
name: out_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
width: pw,
|
||||
height: ph,
|
||||
captured_at,
|
||||
captured_offset,
|
||||
camera,
|
||||
lens,
|
||||
iso,
|
||||
file_size: std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0),
|
||||
thumbnails,
|
||||
};
|
||||
|
||||
Ok(Some(MergeEvent::Done {
|
||||
path: out_path,
|
||||
staged,
|
||||
width: out_w,
|
||||
height: out_h,
|
||||
composite: Box::new(composite),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The crop as `write_linear_dng` writes it: inside the frame.
|
||||
fn clamp_crop(r: dr_export::Rect, width: u32, height: u32) -> dr_export::Rect {
|
||||
let x = r.x.min(width.saturating_sub(1));
|
||||
let y = r.y.min(height.saturating_sub(1));
|
||||
dr_export::Rect {
|
||||
x,
|
||||
y,
|
||||
width: r.width.min(width - x),
|
||||
height: r.height.min(height - y),
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy the detector reads: the frame through the camera-space tap at
|
||||
/// proxy size, upright, as gamma-encoded grey.
|
||||
///
|
||||
@@ -1088,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.
|
||||
///
|
||||
/// 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
|
||||
/// gains applied. Ghosting here is the alignment's error and banding is
|
||||
/// the gains', which is exactly what the photographer is being asked to
|
||||
/// look at. Fitted to 1600 px across.
|
||||
/// matrix, a gamma, and the frames joined along the same seams the merge
|
||||
/// will use, with their gains applied. Ghosting here is the alignment's
|
||||
/// error and banding is the gains', which is exactly what the photographer
|
||||
/// 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,
|
||||
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
||||
/// a gamma — which is also what the border filler was trained on.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn preview_planes(
|
||||
colour: &[&[f32]],
|
||||
proxies: &[&Gray],
|
||||
alignment: &Alignment,
|
||||
gains: &[f32],
|
||||
seams: Option<(&SeamMap, f64)>,
|
||||
projection: Projection,
|
||||
first: &RawImage,
|
||||
max_width: usize,
|
||||
@@ -1125,7 +1322,7 @@ fn preview_planes(
|
||||
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
||||
let d = projection.to_direction(scale, u, v);
|
||||
let mut sum = [0.0f32; 3];
|
||||
let mut n = 0u32;
|
||||
let mut n = 0.0f32;
|
||||
for (k, g) in proxies.iter().enumerate() {
|
||||
let Some((x, y)) = cameras.project(k, d) else {
|
||||
continue;
|
||||
@@ -1137,16 +1334,21 @@ fn preview_planes(
|
||||
let i = (y as usize * g.width + x as usize) * 3;
|
||||
let cam =
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
known[o] = true;
|
||||
}
|
||||
@@ -1471,17 +1673,29 @@ fn stem(p: &Path) -> String {
|
||||
.unwrap_or_else(|| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Where the composite is written until it is whole: `x.dng.part` for
|
||||
/// `x.dng`.
|
||||
fn part_name(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".part");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// `name` in `dir`, numbered if that name is taken: a merge never
|
||||
/// overwrites (FR-MRG-3).
|
||||
fn unused_name(dir: &Path, name: &str) -> PathBuf {
|
||||
let mut candidate = dir.join(name);
|
||||
/// overwrites (FR-MRG-3). `name` in `dir`, or `name-2`, `name-3`… — the first that neither `dir`
|
||||
/// nor `taken` (the names already at the destination) holds.
|
||||
fn unused_name(dir: &Path, name: &str, taken: &std::collections::HashSet<String>) -> PathBuf {
|
||||
let mut candidate = name.to_string();
|
||||
let base = stem(Path::new(name));
|
||||
let mut n = 2;
|
||||
while candidate.exists() {
|
||||
candidate = dir.join(format!("{base}-{n}.dng"));
|
||||
while dir.join(&candidate).exists()
|
||||
|| part_name(&dir.join(&candidate)).exists()
|
||||
|| taken.contains(&candidate)
|
||||
{
|
||||
candidate = format!("{base}-{n}.dng");
|
||||
n += 1;
|
||||
}
|
||||
candidate
|
||||
dir.join(candidate)
|
||||
}
|
||||
|
||||
/// Drain everything a job has said so far.
|
||||
@@ -1568,6 +1782,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_merge_is_not_named_over_the_first_in_the_library() {
|
||||
// The outbox is empty once the first has uploaded, so only the
|
||||
// catalog knows the name is taken — and the upload replaces
|
||||
// whatever is at its name.
|
||||
let dir = std::env::temp_dir().join(format!("dr-merge-names-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let none = std::collections::HashSet::new();
|
||||
assert_eq!(
|
||||
unused_name(&dir, "a-pano.dng", &none),
|
||||
dir.join("a-pano.dng")
|
||||
);
|
||||
let taken: std::collections::HashSet<String> =
|
||||
["a-pano.dng".to_string(), "a-pano-2.dng".to_string()].into();
|
||||
assert_eq!(
|
||||
unused_name(&dir, "a-pano.dng", &taken),
|
||||
dir.join("a-pano-3.dng")
|
||||
);
|
||||
// And one still being written in the outbox holds its name too.
|
||||
std::fs::write(part_name(&dir.join("a-pano.dng")), b"").unwrap();
|
||||
assert_eq!(
|
||||
unused_name(&dir, "a-pano.dng", &none),
|
||||
dir.join("a-pano-2.dng")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() {
|
||||
// The failure the page could not see: a panic drops the sender with
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! TRACES: FR-MRG-6
|
||||
//! The composite's thumbnails, made by the merge from its own final image.
|
||||
//!
|
||||
//! # Why the merge makes them
|
||||
//!
|
||||
//! A linear DNG as the merge writes it carries no embedded preview, and an
|
||||
//! embedded preview is all the grid's ordinary thumbnail path reads — so a
|
||||
//! composite sat in the grid as a blank cell until someone opened it. Making
|
||||
//! the thumbnail afterwards means reading back a file of 800 MB; the merge
|
||||
//! has every pixel of it in hand as the bands go by.
|
||||
//!
|
||||
//! # From the final image, through develop
|
||||
//!
|
||||
//! The bands are box-reduced as they are written — after the border fill,
|
||||
//! so the thumbnail shows what the file holds — into a copy
|
||||
//! [`SOURCE_EDGE`] pixels long. That copy is written as a linear DNG in
|
||||
//! memory with the composite's own profile, header and crop, and opened
|
||||
//! exactly as develop opens a file on first sight (`crate::open_session`):
|
||||
//! the same decode, the same default graph with its default view transform
|
||||
//! and tone mapping, the file's as-shot white balance and the conversion
|
||||
//! from the working space to the display's. The thumbnail is therefore the
|
||||
//! photograph the photographer will see on opening it, not a second,
|
||||
//! plausible conversion — the merge's own quick look ([`crate::merge`]'s
|
||||
//! `Look`) is exactly the naive linear-to-gamma picture this is not.
|
||||
//!
|
||||
//! The reduction is develop's too, in effect: a composite past one texture
|
||||
//! is developed from a box-reduced copy (`DevelopSession::open_owned`), and
|
||||
//! this is the same box at a coarser step.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use dr_thumbs::{ThumbSize, Thumbnail};
|
||||
|
||||
/// The long edge of the reduced copy the thumbnails are developed from:
|
||||
/// twice the largest thumbnail, so the develop render resamples down rather
|
||||
/// than up, and small enough to cost the merge a few tens of megabytes.
|
||||
pub const SOURCE_EDGE: u32 = 4096;
|
||||
|
||||
/// The composite box-reduced as its bands go by.
|
||||
pub struct Reduced {
|
||||
width: u32,
|
||||
height: u32,
|
||||
factor: u32,
|
||||
/// Per reduced pixel, per channel: the sum of the full-resolution
|
||||
/// samples that fall in its box.
|
||||
sums: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Reduced {
|
||||
/// A reduction of a `width × height` composite to at most `long_edge`
|
||||
/// pixels on its longer side, by a whole factor.
|
||||
pub fn new(width: u32, height: u32, long_edge: u32) -> Self {
|
||||
let factor = width.max(height).div_ceil(long_edge.max(1)).max(1);
|
||||
let (w, h) = (width.div_ceil(factor), height.div_ceil(factor));
|
||||
Reduced {
|
||||
width,
|
||||
height,
|
||||
factor,
|
||||
sums: vec![0; w as usize * h as usize * 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// The reduced size.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(
|
||||
self.width.div_ceil(self.factor),
|
||||
self.height.div_ceil(self.factor),
|
||||
)
|
||||
}
|
||||
|
||||
/// The whole factor each side is divided by.
|
||||
pub fn factor(&self) -> u32 {
|
||||
self.factor
|
||||
}
|
||||
|
||||
/// Fold in `rows` rows of the composite starting at `first_row`,
|
||||
/// interleaved RGB at the full width.
|
||||
pub fn push(&mut self, first_row: u32, rows: u32, rgb: &[u16]) {
|
||||
let (rw, _) = self.size();
|
||||
let (w, f) = (self.width as usize, self.factor as usize);
|
||||
for r in 0..rows as usize {
|
||||
let y = first_row as usize + r;
|
||||
if y >= self.height as usize {
|
||||
break;
|
||||
}
|
||||
let row = &rgb[r * w * 3..(r + 1) * w * 3];
|
||||
let out = (y / f) * rw as usize * 3;
|
||||
for (x, px) in row.chunks_exact(3).enumerate() {
|
||||
let o = out + (x / f) * 3;
|
||||
self.sums[o] += u32::from(px[0]);
|
||||
self.sums[o + 1] += u32::from(px[1]);
|
||||
self.sums[o + 2] += u32::from(px[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The averages, as the DNG's `u16` samples. A box cut short by the
|
||||
/// edge of the composite is the mean of what it holds.
|
||||
pub fn finish(self) -> (u32, u32, Vec<u16>) {
|
||||
let (rw, rh) = self.size();
|
||||
let f = self.factor;
|
||||
let mut out = vec![0u16; self.sums.len()];
|
||||
for y in 0..rh {
|
||||
let bh = f.min(self.height - y * f);
|
||||
for x in 0..rw {
|
||||
let bw = f.min(self.width - x * f);
|
||||
let n = bh * bw;
|
||||
let i = (y * rw + x) as usize * 3;
|
||||
for c in 0..3 {
|
||||
out[i + c] = ((self.sums[i + c] + n / 2) / n) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
(rw, rh, out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which size classes a composite of this shape is thumbnailed at: the two
|
||||
/// every photograph has, and the wide classes its cell can be drawn at —
|
||||
/// its own, from the one lookup the grid packs by, and each narrower one a
|
||||
/// grid with too few columns falls back to.
|
||||
pub fn classes(width: u32, height: u32) -> Vec<ThumbSize> {
|
||||
let aspect = width as f32 / height.max(1) as f32;
|
||||
let natural = crate::library_ui::layout::natural_span(Some(aspect));
|
||||
let mut out = vec![ThumbSize::Grid, ThumbSize::Large];
|
||||
out.extend((2..=natural).filter_map(ThumbSize::wide));
|
||||
out
|
||||
}
|
||||
|
||||
/// Develop the reduced composite as develop would open the file, and render
|
||||
/// it at each of `classes`.
|
||||
///
|
||||
/// `crop` is the rectangle the file opens on, in the composite's pixels;
|
||||
/// it is carried to the reduced copy so the thumbnail is of the picture
|
||||
/// and not of the border around it.
|
||||
pub fn render(
|
||||
ctx: &dr_gpu::GpuContext,
|
||||
reduced: Reduced,
|
||||
profile: &dr_export::DngProfile,
|
||||
header: &dr_export::SourceMetadata,
|
||||
crop: Option<dr_export::Rect>,
|
||||
decoder: &dyn dr_decode::Decoder,
|
||||
classes: &[ThumbSize],
|
||||
) -> Result<Vec<(ThumbSize, Thumbnail)>, String> {
|
||||
let f = reduced.factor();
|
||||
let (w, h, samples) = reduced.finish();
|
||||
let crop = crop.map(|r| {
|
||||
let x = (r.x / f).min(w.saturating_sub(1));
|
||||
let y = (r.y / f).min(h.saturating_sub(1));
|
||||
dr_export::Rect {
|
||||
x,
|
||||
y,
|
||||
width: (r.width / f).clamp(1, w - x),
|
||||
height: (r.height / f).clamp(1, h - y),
|
||||
}
|
||||
});
|
||||
let mut file = Cursor::new(Vec::new());
|
||||
let mut once = Some(samples);
|
||||
dr_export::write_linear_dng(
|
||||
&mut file,
|
||||
w,
|
||||
h,
|
||||
h,
|
||||
profile,
|
||||
Some(header),
|
||||
|_, buf| {
|
||||
buf.extend_from_slice(&once.take().unwrap_or_default());
|
||||
Ok(())
|
||||
},
|
||||
|| crop,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let bytes = file.into_inner();
|
||||
|
||||
// The file as develop reads it on first open: its header, then the
|
||||
// pixels, through the one function every develop session starts from.
|
||||
let meta = decoder.metadata(&bytes).map_err(|e| e.to_string())?;
|
||||
let mut session = crate::open_session(ctx, decoder, &bytes, &meta)?;
|
||||
let mut out = Vec::with_capacity(classes.len());
|
||||
for &class in classes {
|
||||
let (tw, th, rgba) = session.render_thumbnail(class.edge())?;
|
||||
let jpeg = dr_thumbs::encode_rgba(tw, th, &rgba).map_err(|e| e.to_string())?;
|
||||
out.push((
|
||||
class,
|
||||
Thumbnail {
|
||||
width: tw,
|
||||
height: th,
|
||||
bytes: jpeg,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panorama_is_thumbnailed_for_its_wide_cell_and_a_squat_merge_is_not() {
|
||||
use ThumbSize::*;
|
||||
assert_eq!(
|
||||
classes(22_000, 5_600),
|
||||
[Grid, Large, Wide2, Wide3, Wide4],
|
||||
"a 3.9:1 panorama spans four"
|
||||
);
|
||||
assert_eq!(classes(8_000, 4_000), [Grid, Large, Wide2]);
|
||||
assert_eq!(classes(9_000, 6_000), [Grid, Large]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reduction_averages_its_boxes_and_the_ragged_edge() {
|
||||
// 5 × 3 by 2: boxes of 2 × 2, then 1 × 2 at the right, 2 × 1 and
|
||||
// 1 × 1 along the bottom.
|
||||
let mut r = Reduced::new(5, 3, 3);
|
||||
assert_eq!(r.factor(), 2);
|
||||
assert_eq!(r.size(), (3, 2));
|
||||
let px = |x: u16, y: u16| [x * 10 + y, 0, 1000];
|
||||
let rows: Vec<u16> = (0..3)
|
||||
.flat_map(|y| (0..5).flat_map(move |x| px(x, y)))
|
||||
.collect();
|
||||
// In two bands, as the merge hands them over.
|
||||
r.push(0, 2, &rows[..5 * 2 * 3]);
|
||||
r.push(2, 1, &rows[5 * 2 * 3..]);
|
||||
let (w, h, out) = r.finish();
|
||||
assert_eq!((w, h), (3, 2));
|
||||
let at = |x: usize, y: usize| out[(y * 3 + x) * 3];
|
||||
// (0,0),(1,0),(0,1),(1,1) → 0, 10, 1, 11: mean 5.5, rounded.
|
||||
assert_eq!(at(0, 0), 6);
|
||||
// The right-hand column is one pixel wide: (4,0) and (4,1).
|
||||
assert_eq!(at(2, 0), 41, "40 and 41, rounded");
|
||||
// The bottom-right is a single pixel.
|
||||
assert_eq!(at(2, 1), 42);
|
||||
assert!(out.chunks_exact(3).all(|p| p[2] == 1000));
|
||||
}
|
||||
|
||||
/// A synthetic composite in camera space: a sky that brightens to a
|
||||
/// clipped band, green ground with texture, and a blown sun.
|
||||
fn composite(width: u32, height: u32, white: u32) -> Vec<u16> {
|
||||
let mut out = Vec::with_capacity((width * height * 3) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let fy = y as f32 / height as f32;
|
||||
let fx = x as f32 / width as f32;
|
||||
let tex = ((x / 7 + y / 5) % 2) as f32 * 0.03;
|
||||
let cam = if fy < 0.45 {
|
||||
// Sky: blue-ish in camera space, brighter toward the top
|
||||
// and toward the right, clipping in the top corner.
|
||||
let b = (0.25 + 0.9 * (1.0 - fy / 0.45) * fx).min(1.2);
|
||||
[b * 0.45, b * 0.8, b]
|
||||
} else {
|
||||
let g = 0.08 + 0.25 * fx + tex;
|
||||
[g * 0.6, g, g * 0.4]
|
||||
};
|
||||
let sun = ((fx - 0.8).powi(2) + (fy - 0.2).powi(2)).sqrt() < 0.04;
|
||||
let cam = if sun { [1.0, 1.0, 1.0] } else { cam };
|
||||
for c in cam {
|
||||
out.push(((c * white as f32).round() as u32).min(white) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn profile(white: u32) -> dr_export::DngProfile {
|
||||
// A Canon 6D's D65 matrix and as-shot neutral, as the fixture's
|
||||
// composite carries them.
|
||||
dr_export::DngProfile {
|
||||
unique_model: "Canon EOS 6D".into(),
|
||||
calibrations: vec![(
|
||||
21,
|
||||
[
|
||||
[0.7034, -0.0804, -0.1014],
|
||||
[-0.4420, 1.2564, 0.2058],
|
||||
[-0.0851, 0.1994, 0.5758],
|
||||
],
|
||||
)],
|
||||
as_shot_neutral: [0.4985, 1.0, 0.6505],
|
||||
white_level: white,
|
||||
}
|
||||
}
|
||||
|
||||
/// Luma, in 0..255, for each pixel of an RGBA buffer.
|
||||
fn luma(rgba: &[u8]) -> Vec<f32> {
|
||||
rgba.chunks_exact(4)
|
||||
.map(|p| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn stats(mut l: Vec<f32>) -> (f32, f32, f32) {
|
||||
let mean = l.iter().sum::<f32>() / l.len() as f32;
|
||||
l.sort_by(f32::total_cmp);
|
||||
let at = |q: f32| l[((l.len() - 1) as f32 * q).round() as usize];
|
||||
(mean, at(0.95), at(0.995))
|
||||
}
|
||||
|
||||
/// FR-MRG-6's acceptance: the thumbnail made during the merge is the
|
||||
/// picture develop shows on first opening the composite — its overall
|
||||
/// level and its highlights — and not a naive conversion of the linear
|
||||
/// data, which comes out a stop and more away on both.
|
||||
#[test]
|
||||
fn the_thumbnail_is_what_develop_shows_on_first_open() {
|
||||
let Some(ctx) = crate::develop::test_support::headless() else {
|
||||
eprintln!("no GPU; skipping");
|
||||
return;
|
||||
};
|
||||
// Past `SOURCE_EDGE`, so the thumbnails come from a reduced copy.
|
||||
let (width, height, white) = (4600u32, 1160u32, 13_023u32);
|
||||
let pixels = composite(width, height, white);
|
||||
let profile = profile(white);
|
||||
let header = dr_export::SourceMetadata {
|
||||
make: Some("Canon".into()),
|
||||
model: Some("Canon EOS 6D".into()),
|
||||
captured_at: Some(1_600_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
let crop = dr_export::Rect {
|
||||
x: 40,
|
||||
y: 30,
|
||||
width: 4500,
|
||||
height: 1080,
|
||||
};
|
||||
|
||||
// Develop's side: the whole composite written as the merge writes
|
||||
// it, opened as develop opens it, rendered for the grid.
|
||||
let rows_per_strip = 256u32;
|
||||
let mut file = Cursor::new(Vec::new());
|
||||
dr_export::write_linear_dng(
|
||||
&mut file,
|
||||
width,
|
||||
height,
|
||||
rows_per_strip,
|
||||
&profile,
|
||||
Some(&header),
|
||||
|k, buf| {
|
||||
let from = k * (rows_per_strip * width * 3) as usize;
|
||||
let to = (from + (rows_per_strip * width * 3) as usize).min(pixels.len());
|
||||
buf.extend_from_slice(&pixels[from..to]);
|
||||
Ok(())
|
||||
},
|
||||
|| Some(crop),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = file.into_inner();
|
||||
let decoder = dr_decode::default();
|
||||
let meta = decoder.metadata(&bytes).unwrap();
|
||||
let mut developed = crate::open_session(&ctx, decoder, &bytes, &meta).unwrap();
|
||||
|
||||
// The merge's side: the same bands, reduced as they pass.
|
||||
let mut reduced = Reduced::new(width, height, SOURCE_EDGE);
|
||||
assert_eq!(reduced.factor(), 2);
|
||||
for first in (0..height).step_by(rows_per_strip as usize) {
|
||||
let rows = rows_per_strip.min(height - first);
|
||||
let from = (first * width * 3) as usize;
|
||||
reduced.push(
|
||||
first,
|
||||
rows,
|
||||
&pixels[from..from + (rows * width * 3) as usize],
|
||||
);
|
||||
}
|
||||
let thumbs = render(
|
||||
&ctx,
|
||||
reduced,
|
||||
&profile,
|
||||
&header,
|
||||
Some(crop),
|
||||
decoder,
|
||||
&classes(crop.width, crop.height),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The naive picture: the linear camera values balanced and gamma
|
||||
// encoded, with no matrix, no curve and no highlight handling — what
|
||||
// the merge's preview draws.
|
||||
let naive = {
|
||||
let mut l = Vec::new();
|
||||
for y in (crop.y..crop.y + crop.height).step_by(4) {
|
||||
for x in (crop.x..crop.x + crop.width).step_by(4) {
|
||||
let i = ((y * width + x) * 3) as usize;
|
||||
let v = |c: usize| {
|
||||
let lin = pixels[i + c] as f32 / white as f32 / profile.as_shot_neutral[c];
|
||||
lin.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0
|
||||
};
|
||||
l.push(0.2126 * v(0) + 0.7152 * v(1) + 0.0722 * v(2));
|
||||
}
|
||||
}
|
||||
stats(l)
|
||||
};
|
||||
|
||||
for (class, thumb) in thumbs {
|
||||
let (tw, th, ours) = dr_thumbs::decode_rgba(&thumb.bytes).unwrap();
|
||||
let (dw, dh, theirs) = developed.render_thumbnail(class.edge()).unwrap();
|
||||
// Through the store's encoding too: both are JPEGs in the store,
|
||||
// and it is the encoding, not the development, that rounds a
|
||||
// blown sun off to 247.
|
||||
let theirs = {
|
||||
let jpeg = dr_thumbs::encode_rgba(dw, dh, &theirs).unwrap();
|
||||
dr_thumbs::decode_rgba(&jpeg).unwrap().2
|
||||
};
|
||||
// The picture, not the border: the crop's shape at the class's
|
||||
// size, give or take the reduction's rounding.
|
||||
assert!(
|
||||
(tw as i64 - dw as i64).abs() <= 2 && (th as i64 - dh as i64).abs() <= 2,
|
||||
"{class:?}: {tw}×{th} against develop's {dw}×{dh}"
|
||||
);
|
||||
let (m0, p95_0, p995_0) = stats(luma(&ours));
|
||||
let (m1, p95_1, p995_1) = stats(luma(&theirs));
|
||||
eprintln!(
|
||||
"{class:?}: mean {m0:.1}/{m1:.1}, p95 {p95_0:.1}/{p95_1:.1}, \
|
||||
p99.5 {p995_0:.1}/{p995_1:.1}; naive {naive:?}"
|
||||
);
|
||||
assert!((m0 - m1).abs() <= 3.0, "{class:?} mean {m0} vs {m1}");
|
||||
// And the tolerance means something: the conversion this
|
||||
// replaces is nowhere near it.
|
||||
assert!(
|
||||
(naive.0 - m1).abs() > 12.0 || (naive.1 - p95_1).abs() > 12.0,
|
||||
"a naive conversion ({naive:?}) would pass as well"
|
||||
);
|
||||
assert!(
|
||||
(p95_0 - p95_1).abs() <= 4.0,
|
||||
"{class:?} p95 {p95_0} vs {p95_1}"
|
||||
);
|
||||
assert!(
|
||||
(p995_0 - p995_1).abs() <= 4.0,
|
||||
"{class:?} p99.5 {p995_0} vs {p995_1}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
-13
@@ -8,9 +8,10 @@
|
||||
//! opens the page; a timer drains the job's events into the page's
|
||||
//! properties. When the alignment arrives the page shows it and waits.
|
||||
//! A frame's box leaves it out or brings it back, and the job aligns again
|
||||
//! over the rest. "Merge" sends the decision; "Stop" or "Back" cancels. When the file is
|
||||
//! staged, the outbox drains and the library rescans, and the composite
|
||||
//! appears in the grid beside its sources.
|
||||
//! over the rest. "Merge" sends the decision; "Stop" or "Back" cancels. When
|
||||
//! the file is staged the composite is catalogued at once and appears in the
|
||||
//! grid beside its sources; the outbox drains, and the scan that follows the
|
||||
//! upload gives it the identity the server assigned (FR-MRG-6).
|
||||
|
||||
use crate::executors::{self, Executor};
|
||||
use std::cell::{Cell, RefCell};
|
||||
@@ -36,6 +37,29 @@ struct Job {
|
||||
cancel: Cancel,
|
||||
names: Vec<String>,
|
||||
activity: Activity,
|
||||
/// Where in the library the composite goes, for a merge of library
|
||||
/// frames; `None` for one written to a folder on the device.
|
||||
library: Option<Destined>,
|
||||
}
|
||||
|
||||
/// A library merge's destination, kept for the moment it finishes.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Destined {
|
||||
remote_dir: String,
|
||||
sources: Vec<String>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// A composite that has been staged for the library: where it goes, what it
|
||||
/// was made from, and what the job knows about it — enough for the library
|
||||
/// to catalogue it before the upload has finished.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Placed {
|
||||
/// The sources' folder, relative to the library root.
|
||||
pub remote_dir: String,
|
||||
/// The frames, as library paths.
|
||||
pub sources: Vec<String>,
|
||||
pub composite: merge::Composite,
|
||||
}
|
||||
|
||||
pub struct MergeController {
|
||||
@@ -73,16 +97,22 @@ impl MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
/// The names a library folder holds, by folder.
|
||||
pub type NamesIn = Box<dyn Fn(&str) -> std::collections::HashSet<String>>;
|
||||
|
||||
/// What the page needs from the library to start: the account for the
|
||||
/// fetch, and where the outbox is.
|
||||
/// fetch, where the outbox is, and the names a library folder already
|
||||
/// holds, so the composite is not named over one of them.
|
||||
pub struct Context {
|
||||
pub conn: dr_sync::Connection,
|
||||
pub outbox: std::path::PathBuf,
|
||||
pub names_in: NamesIn,
|
||||
}
|
||||
|
||||
/// Wire the page. `sources` yields the selection as fetchable library
|
||||
/// sources; `context` the account; `on_done` runs when a composite has
|
||||
/// been staged, so the caller can drain the outbox and rescan.
|
||||
/// been written, with where it went in the library when it was staged for
|
||||
/// one, so the caller can catalogue it and drain the outbox.
|
||||
pub fn wire<S, C, F>(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<MergeController>,
|
||||
@@ -93,7 +123,7 @@ pub fn wire<S, C, F>(
|
||||
) where
|
||||
S: Fn() -> Vec<crate::export::Source> + 'static,
|
||||
C: Fn() -> Option<Context> + 'static,
|
||||
F: Fn(&AppWindow) + 'static,
|
||||
F: Fn(&AppWindow, Option<&Placed>) + 'static,
|
||||
{
|
||||
wire_start(window, &ctl, gpu, sources, context, on_done);
|
||||
wire_decision(window, &ctl);
|
||||
@@ -111,7 +141,7 @@ fn wire_start<S, C, F>(
|
||||
) where
|
||||
S: Fn() -> Vec<crate::export::Source> + 'static,
|
||||
C: Fn() -> Option<Context> + 'static,
|
||||
F: Fn(&AppWindow) + 'static,
|
||||
F: Fn(&AppWindow, Option<&Placed>) + 'static,
|
||||
{
|
||||
let on_done = Rc::new(on_done);
|
||||
let gpu_for_start = gpu.clone();
|
||||
@@ -165,10 +195,15 @@ fn wire_start<S, C, F>(
|
||||
let remote_dir = first_dir
|
||||
.strip_prefix(&root)
|
||||
.map(|s| s.trim_start_matches('/').to_string())
|
||||
.unwrap_or(first_dir);
|
||||
.unwrap_or_else(|| first_dir.clone());
|
||||
let destination = MergeDestination::Outbox {
|
||||
outbox: context.outbox,
|
||||
remote_dir: remote_dir.clone(),
|
||||
taken: (context.names_in)(&first_dir),
|
||||
};
|
||||
let destined = Destined {
|
||||
remote_dir,
|
||||
sources: sources.iter().map(|(p, _)| p.clone()).collect(),
|
||||
};
|
||||
|
||||
let names: Vec<String> = sources
|
||||
@@ -226,7 +261,16 @@ fn wire_start<S, C, F>(
|
||||
}
|
||||
Some(frames)
|
||||
};
|
||||
start(&w, &ctl, gpu, names, destination, fetch, &on_done);
|
||||
start(
|
||||
&w,
|
||||
&ctl,
|
||||
gpu,
|
||||
names,
|
||||
destination,
|
||||
Some(destined),
|
||||
fetch,
|
||||
&on_done,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -273,6 +317,7 @@ fn wire_start<S, C, F>(
|
||||
gpu,
|
||||
names,
|
||||
MergeDestination::Local(dir),
|
||||
None,
|
||||
fetch,
|
||||
&on_done,
|
||||
);
|
||||
@@ -459,14 +504,18 @@ fn wire_stop_and_leave(window: &AppWindow, ctl: &Rc<MergeController>) {
|
||||
/// Start a job: `fetch` runs first on the job's thread and hands back the
|
||||
/// frames (or reports why not and returns `None`); the job follows on the
|
||||
/// same thread. The page opens clean, and a timer drains the events.
|
||||
// Each argument is a different part of the job: where it runs, what it is
|
||||
// called, where it goes, how its frames arrive, and who is told.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start<Fetch>(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<MergeController>,
|
||||
gpu: dr_gpu::GpuContext,
|
||||
names: Vec<String>,
|
||||
destination: MergeDestination,
|
||||
library: Option<Destined>,
|
||||
fetch: Fetch,
|
||||
on_done: &Rc<impl Fn(&AppWindow) + 'static>,
|
||||
on_done: &Rc<impl Fn(&AppWindow, Option<&Placed>) + 'static>,
|
||||
) where
|
||||
Fetch: FnOnce(&Sender<MergeEvent>, &Cancel) -> Option<Vec<MergeInput>> + Send + 'static,
|
||||
{
|
||||
@@ -495,6 +544,7 @@ fn start<Fetch>(
|
||||
cancel,
|
||||
names,
|
||||
activity,
|
||||
library,
|
||||
});
|
||||
*ctl.report.borrow_mut() = None;
|
||||
ctl.projection.set(0);
|
||||
@@ -547,7 +597,11 @@ fn chip_projection(i: i32) -> Option<dr_pano::Projection> {
|
||||
}
|
||||
|
||||
/// Take everything the job has said and reflect it on the page.
|
||||
fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&AppWindow)>) {
|
||||
fn drain(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<MergeController>,
|
||||
on_done: &Rc<impl Fn(&AppWindow, Option<&Placed>)>,
|
||||
) {
|
||||
let (events, gone) = {
|
||||
let job = ctl.job.borrow();
|
||||
let Some(job) = job.as_ref() else { return };
|
||||
@@ -606,6 +660,7 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
|
||||
staged,
|
||||
width,
|
||||
height,
|
||||
composite,
|
||||
} => {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -616,7 +671,7 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
|
||||
window.set_merge_done(true);
|
||||
window.set_merge_status(
|
||||
if staged {
|
||||
format!("{name} ({width}×{height}) is queued to go beside its sources.")
|
||||
format!("{name} ({width}×{height}) is in the library, beside its sources.")
|
||||
} else {
|
||||
format!("{name} ({width}×{height}) written to {}.", path.display())
|
||||
}
|
||||
@@ -626,7 +681,18 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
|
||||
job.activity.finish(format!("{name} merged"));
|
||||
}
|
||||
finished = true;
|
||||
on_done(window);
|
||||
let placed = ctl
|
||||
.job
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|j| j.library.clone())
|
||||
.filter(|_| staged)
|
||||
.map(|d| Placed {
|
||||
remote_dir: d.remote_dir,
|
||||
sources: d.sources,
|
||||
composite: *composite,
|
||||
});
|
||||
on_done(window, placed.as_ref());
|
||||
}
|
||||
MergeEvent::Failed(e) => {
|
||||
window.set_merge_running(false);
|
||||
|
||||
@@ -1823,7 +1823,7 @@ mod tests {
|
||||
let rows = presets.rows();
|
||||
assert!(rows.iter().all(|r| r.folder && r.depth == 0), "{rows:?}");
|
||||
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");
|
||||
let film: Vec<_> = presets
|
||||
@@ -1835,7 +1835,7 @@ mod tests {
|
||||
assert_eq!(film, ["Colour", "Cinema", "Black and white"]);
|
||||
|
||||
presets.toggle("shipped/Film");
|
||||
assert_eq!(presets.rows().len(), 3);
|
||||
assert_eq!(presets.rows().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -712,6 +712,7 @@ fn metadata(
|
||||
captured_at: md.captured_at,
|
||||
captured_offset: md.captured_offset,
|
||||
camera: crate::library::camera_label(md.make.as_deref(), md.model.as_deref()),
|
||||
size: MetadataFound::upright_size(&md),
|
||||
lens: md.lens.map(|l| l.trim().to_string()),
|
||||
iso: md.iso,
|
||||
},
|
||||
@@ -724,6 +725,7 @@ fn metadata(
|
||||
camera: None,
|
||||
lens: None,
|
||||
iso: None,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -738,6 +740,9 @@ fn metadata(
|
||||
[target.image.0 as i64],
|
||||
)
|
||||
.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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ use jni::strings::JNIStr;
|
||||
/// Run `body` with the application context ndk_context holds, on whatever
|
||||
/// thread this is. It is a `Context`, not the activity — see
|
||||
/// `FolderPicker.start` for what that changes.
|
||||
fn call<T>(
|
||||
pub(crate) fn call<T>(
|
||||
what: &str,
|
||||
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
||||
) -> Result<T, String> {
|
||||
@@ -62,7 +62,7 @@ fn call<T>(
|
||||
/// 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",
|
||||
/// which is what the first build on the tablet did.
|
||||
fn class<'local>(
|
||||
pub(crate) fn class<'local>(
|
||||
env: &mut jni::Env<'local>,
|
||||
activity: &JObject,
|
||||
name: &JNIStr,
|
||||
@@ -80,7 +80,7 @@ fn class<'local>(
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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);
|
||||
|
||||
// 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");
|
||||
|
||||
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 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 after = top_row_span(&zoomed, w);
|
||||
|
||||
|
||||
+66
-19
@@ -22,7 +22,7 @@ import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { MergePage, MergeFrameRow } from "merge.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 { Keys } from "keys.slint";
|
||||
|
||||
@@ -34,7 +34,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||
// hooked through `window.global::<Adjustments>()` and only what this document
|
||||
// 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.
|
||||
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 { Library }
|
||||
export { ExportOptions }
|
||||
@@ -569,6 +569,7 @@ export component AppWindow inherits Window {
|
||||
in property <int> import-volume-selected: -1;
|
||||
in property <string> import-card-path: "";
|
||||
in property <bool> import-card-looks-right: false;
|
||||
in property <bool> import-needs-access: false;
|
||||
in property <bool> import-surveying: false;
|
||||
in property <string> import-survey-summary: "";
|
||||
in property <string> import-upload-target: "";
|
||||
@@ -630,6 +631,7 @@ export component AppWindow inherits Window {
|
||||
callback import-card-path-changed(string);
|
||||
callback import-choose-card();
|
||||
callback import-refresh-volumes();
|
||||
callback import-grant-access();
|
||||
callback import-template-changed(string);
|
||||
callback import-mode-picked(int);
|
||||
callback import-duplicate-picked(int);
|
||||
@@ -1122,6 +1124,7 @@ in property <bool> panel-visible: true;
|
||||
volume-selected: root.import-volume-selected;
|
||||
card-path: root.import-card-path;
|
||||
card-looks-right: root.import-card-looks-right;
|
||||
needs-access: root.import-needs-access;
|
||||
surveying: root.import-surveying;
|
||||
survey-summary: root.import-survey-summary;
|
||||
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); }
|
||||
choose-card => { root.import-choose-card(); }
|
||||
refresh-volumes() => { root.import-refresh-volumes(); }
|
||||
grant-access() => { root.import-grant-access(); }
|
||||
template-changed(t) => { root.import-template-changed(t); }
|
||||
mode-picked(i) => { root.import-mode-picked(i); }
|
||||
duplicate-picked(i) => { root.import-duplicate-picked(i); }
|
||||
@@ -1463,6 +1467,7 @@ in property <bool> panel-visible: true;
|
||||
expanded: root.expanded;
|
||||
cells: Library.library-cells;
|
||||
total: Library.library-total;
|
||||
total-slots: Library.library-total-slots;
|
||||
scanning: Library.library-scanning;
|
||||
opening: Library.library-opening;
|
||||
scan-status: Library.library-status;
|
||||
@@ -1584,9 +1589,13 @@ in property <bool> panel-visible: true;
|
||||
assign-keyword(word) => { Library.library-assign-keyword(word); }
|
||||
unassign-keyword(word) => { Library.library-unassign-keyword(word); }
|
||||
cursor: Library.library-cursor;
|
||||
cursor-slot: Library.library-cursor-slot;
|
||||
move-cursor(delta, extend) => {
|
||||
Library.library-move-cursor(delta, extend);
|
||||
}
|
||||
move-cursor-rows(rows, extend) => {
|
||||
Library.library-move-cursor-rows(rows, extend);
|
||||
}
|
||||
open-cursor() => { Library.library-open-cursor(); }
|
||||
drag-image: Library.library-drag-image;
|
||||
drag-payload() => { return Library.library-drag-payload(); }
|
||||
@@ -1818,8 +1827,10 @@ in property <bool> panel-visible: true;
|
||||
clip: true;
|
||||
|
||||
canvas-image := Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
x: parent.fit-x;
|
||||
y: parent.fit-y;
|
||||
width: parent.fit-w;
|
||||
height: parent.fit-h;
|
||||
source: root.canvas;
|
||||
image-fit: contain;
|
||||
// TRACES: FR-UI-4
|
||||
@@ -1867,8 +1878,10 @@ in property <bool> panel-visible: true;
|
||||
// original straight away, and the last draft of the edit
|
||||
// fades out over it rather than vanishing.
|
||||
previous-frame := Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
x: canvas-image.x;
|
||||
y: canvas-image.y;
|
||||
width: canvas-image.width;
|
||||
height: canvas-image.height;
|
||||
source: root.canvas-previous;
|
||||
image-fit: contain;
|
||||
image-rendering: canvas-image.image-rendering;
|
||||
@@ -1936,15 +1949,54 @@ in property <bool> panel-visible: true;
|
||||
// The overlay is placed against *this*, not against the whole
|
||||
// area — otherwise the crop rect drifts off the picture on any
|
||||
// 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-h: root.canvas.height > 0 ? root.canvas.height : 1;
|
||||
property <float> box-w: self.width / 1px;
|
||||
property <float> box-h: self.height / 1px;
|
||||
property <float> box-w: self.fit-w / 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 <length> shown-w: self.src-w * 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-y: (self.height - self.shown-h) / 2;
|
||||
property <length> shown-x: self.fit-x + (self.fit-w - self.shown-w) / 2;
|
||||
property <length> shown-y: self.fit-y + (self.fit-h - self.shown-h) / 2;
|
||||
|
||||
// Empty and error states say what happened rather than
|
||||
// showing a blank canvas.
|
||||
@@ -2899,15 +2951,10 @@ in property <bool> panel-visible: true;
|
||||
// the thing that could not be reached. They stack
|
||||
// instead — the controls ride on top of the roll's
|
||||
// band, at whatever height it currently stands.
|
||||
property <length> roll-clear:
|
||||
Library.library-total > 0
|
||||
? (root.roll-open
|
||||
? Theme.roll-strip + Theme.roll-reach
|
||||
: 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; }
|
||||
// The canvas's measure of the band, animated there
|
||||
// with the roll's own travel so these rise with the
|
||||
// strip rather than jumping ahead of it.
|
||||
property <length> roll-clear: canvas-area.roll-band;
|
||||
|
||||
x: 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-h: 1.0;
|
||||
|
||||
/// A dragged crop rect — `x`, `y`, `width`, `height`, then the corner the
|
||||
/// drag is holding as a `0`/`1` pair, or `-1, -1` where the whole rect is
|
||||
/// being moved and its shape must not be touched.
|
||||
/// A dragged crop rect — `x`, `y`, `width`, `height`, then the handle the
|
||||
/// drag is holding as a point of the rect: a `0`/`1` pair for a corner,
|
||||
/// `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
|
||||
/// rect onto a ratio has to know which of its corners is nailed down, and
|
||||
/// The handle is what an aspect lock needs and cannot infer: reshaping a
|
||||
/// 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
|
||||
/// held corner rather than as the fixed one because that is what is
|
||||
/// written here — Rust takes the opposite corner.
|
||||
/// held point rather than as the fixed one because that is what is
|
||||
/// 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);
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
@@ -189,24 +191,43 @@ export component CropOverlay inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Corner handles. Each drags one corner while the opposite
|
||||
// stays put, which is the only behaviour that lets a crop
|
||||
// be shaped rather than merely moved.
|
||||
for corner in [
|
||||
// Handles: the four edges, then the four corners.
|
||||
//
|
||||
// A corner drags one corner while the opposite stays put, which is the
|
||||
// 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: 1.0, hy: 0.0 },
|
||||
{ hx: 0.0, hy: 1.0 },
|
||||
{ hx: 1.0, hy: 1.0 },
|
||||
]: Rectangle {
|
||||
property <length> size: 18px;
|
||||
x: parent.rx + corner.hx * parent.rw - self.size / 2;
|
||||
y: parent.ry + corner.hy * parent.rh - self.size / 2;
|
||||
width: self.size;
|
||||
height: self.size;
|
||||
property <bool> across: handle.hx == 0.5;
|
||||
property <bool> along: handle.hy == 0.5;
|
||||
// A corner is a square; an edge is a bar lying along its side, with
|
||||
// a hit area as long as the bar and as deep as a corner's.
|
||||
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 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: parent.across ? 24px : (parent.along ? 5px : 12px);
|
||||
height: parent.along ? 24px : (parent.across ? 5px : 12px);
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -214,9 +235,13 @@ export component CropOverlay inherits Rectangle {
|
||||
TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: (corner.hx == corner.hy)
|
||||
? MouseCursor.nwse-resize
|
||||
: MouseCursor.nesw-resize;
|
||||
mouse-cursor: parent.across
|
||||
? MouseCursor.ns-resize
|
||||
: parent.along
|
||||
? MouseCursor.ew-resize
|
||||
: (handle.hx == handle.hy)
|
||||
? MouseCursor.nwse-resize
|
||||
: MouseCursor.nesw-resize;
|
||||
|
||||
property <float> ox;
|
||||
property <float> oy;
|
||||
@@ -246,27 +271,27 @@ export component CropOverlay inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Movement as a fraction of the frame, live while
|
||||
// the handle is held.
|
||||
property <float> dx: self.fraction-x(self.mouse-x) - self.from-x;
|
||||
property <float> dy: self.fraction-y(self.mouse-y) - self.from-y;
|
||||
// Movement as a fraction of the frame, live while the handle is
|
||||
// held — and nothing along an axis the handle does not move, so
|
||||
// an edge dragged at a slant still only moves its own side.
|
||||
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 => {
|
||||
if (!self.pressed) {
|
||||
return;
|
||||
}
|
||||
// Dragging a left/top handle moves the origin
|
||||
// and shrinks the extent by the same amount;
|
||||
// a right/bottom handle moves only the extent.
|
||||
// Rust clamps the result, so an over-drag
|
||||
// slides rather than inverting.
|
||||
// Dragging a left/top handle moves the origin and shrinks the
|
||||
// extent by the same amount; a right/bottom handle moves only
|
||||
// the extent. Rust clamps the result, so an over-drag slides
|
||||
// rather than inverting.
|
||||
root.crop-changed(
|
||||
corner.hx == 0 ? self.ox + self.dx : self.ox,
|
||||
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
||||
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||
corner.hx,
|
||||
corner.hy,
|
||||
handle.hx == 0 ? self.ox + self.dx : self.ox,
|
||||
handle.hy == 0 ? self.oy + self.dy : self.oy,
|
||||
handle.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||
handle.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||
handle.hx,
|
||||
handle.hy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +383,51 @@ export global Capture {
|
||||
/// 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.
|
||||
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> 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,
|
||||
// which is generated from pipeline capabilities rather than written here.
|
||||
//
|
||||
@@ -424,6 +465,17 @@ export component InfoPanel inherits Rectangle {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +75,17 @@ export component ImportPage inherits Rectangle {
|
||||
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
||||
/// sources do not, so this informs and never blocks.
|
||||
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 card-path-changed(string);
|
||||
/// Browse for the source with the platform's dialogue.
|
||||
callback choose-card();
|
||||
callback refresh-volumes();
|
||||
/// Open the system page where that switch is.
|
||||
callback grant-access();
|
||||
|
||||
// --- what is on it ---------------------------------------------------
|
||||
in property <bool> surveying: false;
|
||||
@@ -222,10 +227,27 @@ export component ImportPage inherits Rectangle {
|
||||
Panel {
|
||||
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
|
||||
? "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;
|
||||
}
|
||||
|
||||
|
||||
+52
-17
@@ -483,6 +483,13 @@ export struct LibraryCell {
|
||||
// the one cell on screen is the representative by construction, and a mark
|
||||
// saying so would be telling the user what they can already see.
|
||||
burst-representative: bool,
|
||||
// TRACES: FR-MRG-6
|
||||
// Where the cell sits in the grid's lattice of slots — row
|
||||
// `slot / columns`, column `slot % columns` — and how many slots it
|
||||
// takes: one, or two, three or four for a panorama, or the whole row. Rust's
|
||||
// layout (`library_ui::layout`) is the one place these are computed.
|
||||
slot: int,
|
||||
span: int,
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5
|
||||
@@ -535,8 +542,14 @@ export global Library {
|
||||
callback library-pinch-cells(float);
|
||||
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||||
callback library-pinch-started();
|
||||
/// The slot the grid's first row should show — a slot, not an ordinal:
|
||||
/// see `LibraryCell.slot`.
|
||||
in property <int> library-scroll-to: 0;
|
||||
in property <int> library-scroll-token: 0;
|
||||
/// TRACES: FR-MRG-6
|
||||
/// The slots the whole list takes, which a panorama's cell makes more
|
||||
/// than its count of photographs: what the scrollbar spans.
|
||||
in property <int> library-total-slots: 0;
|
||||
/// Which row of the loaded window is the photograph currently open in
|
||||
/// develop, so the roll can mark it. `-1` when it is not in the window.
|
||||
/// Rust re-finds it on every reload, so it follows the photograph and not
|
||||
@@ -559,6 +572,7 @@ export global Library {
|
||||
/// what the roll happens to hold.
|
||||
callback library-roll-step(int);
|
||||
callback library-columns-changed();
|
||||
/// The first visible row's first slot.
|
||||
callback library-scrolled(int);
|
||||
/// How many cells the grid's viewport shows at once. Rust sizes the
|
||||
/// loaded window from it — see `library_ui::window_move`.
|
||||
@@ -616,7 +630,15 @@ export global Library {
|
||||
// ordinal. Rust owns it — clamping it needs the library's length, and
|
||||
// moving it may have to swap the loaded window underneath.
|
||||
in property <int> library-cursor: -1;
|
||||
/// The slot the cursor's photograph starts at, for keeping its row in
|
||||
/// view.
|
||||
in property <int> library-cursor-slot: -1;
|
||||
callback library-move-cursor(int, bool);
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Move the cursor by rows, which with a panorama's cell in the way is
|
||||
/// not a fixed number of photographs: Rust asks the layout what is under
|
||||
/// the cursor that many rows on.
|
||||
callback library-move-cursor-rows(int, bool);
|
||||
callback library-open-cursor();
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// Keywording the grid's selection. The catalog has been searchable by
|
||||
@@ -1461,6 +1483,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
|
||||
in property <[LibraryCell]> cells;
|
||||
in property <int> total: 0;
|
||||
/// TRACES: FR-MRG-6
|
||||
/// The slots the whole list takes: `total` where every cell is one slot,
|
||||
/// more where a panorama spans several or a row ends short of one.
|
||||
in property <int> total-slots: 0;
|
||||
in property <bool> scanning: false;
|
||||
/// The catalog on this device is being opened and checked before the scan
|
||||
/// starts. Distinct from `scanning` for the reason the empty state below
|
||||
@@ -2036,6 +2062,9 @@ export component LibraryGrid inherits Rectangle {
|
||||
// and the first arrow press picks up where the view already is rather than
|
||||
// teleporting to image zero.
|
||||
in property <int> cursor: -1;
|
||||
/// The slot the cursor's photograph starts at; its row is what is kept
|
||||
/// in view.
|
||||
in property <int> cursor-slot: -1;
|
||||
/// Move the cursor by a number of images; the flag extends the selection
|
||||
/// from the anchor instead of replacing it.
|
||||
///
|
||||
@@ -2043,6 +2072,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// longer than the library, which Rust clamps — so this file needs to know
|
||||
/// neither how many images there are nor where the loaded window starts.
|
||||
callback move-cursor(int, bool);
|
||||
/// Move the cursor by rows. Up and down are not a row's worth of
|
||||
/// photographs once a panorama spans columns, so Rust finds the one
|
||||
/// under the cursor that many rows on.
|
||||
callback move-cursor-rows(int, bool);
|
||||
/// Open the image under the cursor. `Return`, and the reason the arrows
|
||||
/// are worth having: a cull is walk, judge, open, back, without the hand
|
||||
/// ever leaving the keyboard.
|
||||
@@ -2205,7 +2238,6 @@ export component LibraryGrid inherits Rectangle {
|
||||
// start-up. Every month then began a row and was announced wherever
|
||||
// its first cell fell, mid-row included.
|
||||
init => { root.columns-changed(root.columns); }
|
||||
property <int> row-count: ceil(root.cells.length / max(1, columns));
|
||||
|
||||
// --- while a pinch is happening, and just after -----------------------
|
||||
//
|
||||
@@ -2276,7 +2308,8 @@ export component LibraryGrid inherits Rectangle {
|
||||
property <int> viewport-cells: root.columns * (root.visible-rows + 1);
|
||||
changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); }
|
||||
/// Rows the *whole library* occupies, which is what the scrollbar spans.
|
||||
property <int> total-rows: ceil(root.total / max(1, columns));
|
||||
/// Counted in slots, since a panorama's cell takes several.
|
||||
property <int> total-rows: ceil(max(root.total, root.total-slots) / max(1, columns));
|
||||
|
||||
background: Theme.ground;
|
||||
|
||||
@@ -3437,21 +3470,19 @@ export component LibraryGrid inherits Rectangle {
|
||||
return accept;
|
||||
}
|
||||
if (Keys.chord(event) == "Up" || Keys.chord(event) == "Shift+Up") {
|
||||
root.move-cursor(-root.columns, event.modifiers.shift);
|
||||
root.move-cursor-rows(-1, event.modifiers.shift);
|
||||
return accept;
|
||||
}
|
||||
if (Keys.chord(event) == "Down" || Keys.chord(event) == "Shift+Down") {
|
||||
root.move-cursor(root.columns, event.modifiers.shift);
|
||||
root.move-cursor-rows(1, event.modifiers.shift);
|
||||
return accept;
|
||||
}
|
||||
if (Keys.chord(event) == "PageUp" || Keys.chord(event) == "Shift+PageUp") {
|
||||
root.move-cursor(-root.columns * root.visible-rows,
|
||||
event.modifiers.shift);
|
||||
root.move-cursor-rows(-root.visible-rows, event.modifiers.shift);
|
||||
return accept;
|
||||
}
|
||||
if (Keys.chord(event) == "PageDown" || Keys.chord(event) == "Shift+PageDown") {
|
||||
root.move-cursor(root.columns * root.visible-rows,
|
||||
event.modifiers.shift);
|
||||
root.move-cursor-rows(root.visible-rows, event.modifiers.shift);
|
||||
return accept;
|
||||
}
|
||||
// A step longer than the library, clamped at the far end.
|
||||
@@ -3603,7 +3634,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// on screen moves nothing at all, and one that has just left
|
||||
// brings in exactly its own row.
|
||||
property <length> pitch: root.cell-size + Theme.gap;
|
||||
property <int> cursor-row: floor(root.cursor / max(1, root.columns));
|
||||
property <int> cursor-row: floor(max(0, root.cursor-slot) / max(1, root.columns));
|
||||
changed cursor-row => { self.reveal(); }
|
||||
|
||||
function reveal() {
|
||||
@@ -3722,7 +3753,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// Sits in the gap above its row, so it labels the row
|
||||
// rather than displacing it.
|
||||
y: Theme.gap
|
||||
+ floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap)
|
||||
+ floor(cell.slot / root.columns) * (root.cell-size + Theme.gap)
|
||||
- 15px;
|
||||
width: parent.width - 2 * Theme.gap;
|
||||
text: cell.period-heading;
|
||||
@@ -3756,10 +3787,12 @@ export component LibraryGrid inherits Rectangle {
|
||||
// Cells are positioned at their **absolute** place in the
|
||||
// library, not their index in the loaded window: the window
|
||||
// starts at `offset`, so a cell drawn at window-index 0 belongs
|
||||
// wherever `offset` sits in the full grid.
|
||||
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap);
|
||||
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap);
|
||||
width: root.cell-size;
|
||||
// wherever `offset` sits in the full grid. That place is its
|
||||
// slot, which Rust computes (FR-MRG-6): a panorama before it
|
||||
// pushes it along, and a panorama itself spans `span` slots.
|
||||
x: Theme.gap + mod(cell.slot, root.columns) * (root.cell-size + Theme.gap);
|
||||
y: Theme.gap + floor(cell.slot / root.columns) * (root.cell-size + Theme.gap);
|
||||
width: max(1, cell.span) * root.cell-size + (max(1, cell.span) - 1) * Theme.gap;
|
||||
height: root.cell-size;
|
||||
|
||||
// Copy, not move: dropping into a collection files the
|
||||
@@ -4433,12 +4466,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
|
||||
visible: root.held-row >= 0;
|
||||
x: Theme.gap
|
||||
+ mod(root.held-row + root.offset, root.columns) * self.pitch
|
||||
+ mod(root.cells[root.held-row].slot, root.columns) * self.pitch
|
||||
- self.reach;
|
||||
y: Theme.gap
|
||||
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
|
||||
+ floor(root.cells[root.held-row].slot / root.columns) * self.pitch
|
||||
- self.reach;
|
||||
width: root.cell-size + 2 * self.reach;
|
||||
property <int> span: max(1, root.cells[root.held-row].span);
|
||||
width: self.span * root.cell-size + (self.span - 1) * Theme.gap
|
||||
+ 2 * self.reach;
|
||||
height: root.cell-size + 2 * self.reach;
|
||||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import { Icon } from "icons.slint";
|
||||
import { ViewMode } from "session.slint";
|
||||
import { PresetRow, PresetOrigin, PresetFolderRow } from "presets.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
|
||||
// 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);
|
||||
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 {
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
|
||||
Reference in New Issue
Block a user