Compare commits
20
Commits
v0.19.0
...
2c947430e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.19.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ members = [
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.19.3"
|
||||
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.19.3**, thirty-two 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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
+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
|
||||
|
||||
+90
-90
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.19.3
|
||||
# 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
||||
total,
|
||||
to,
|
||||
move |written| {
|
||||
drain_outbox(&library_for_drain);
|
||||
drain_outbox(&library_for_drain, weak.clone());
|
||||
// What the album now holds, and which photograph
|
||||
// each file came from.
|
||||
if let (Some(albums), Some(w)) = (albums.as_ref(), weak.upgrade()) {
|
||||
@@ -1042,9 +1042,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.
|
||||
|
||||
+589
-22
@@ -146,6 +146,20 @@ impl Pending {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Where a file staged for `remote_dir` of the library at `root`, called
|
||||
/// `name`, will be once it is uploaded — spelled as the scan will list it,
|
||||
/// because the catalog row written ahead of the scan is keyed on it.
|
||||
pub(crate) fn staged_remote_path(root: &str, remote_dir: &str, name: &str) -> RemotePath {
|
||||
Pending {
|
||||
local: PathBuf::new(),
|
||||
remote_dir: remote_dir.to_string(),
|
||||
account: false,
|
||||
name: name.to_string(),
|
||||
}
|
||||
.remote_path(root)
|
||||
}
|
||||
|
||||
/// Where an export was put, for the interface to report.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Placed {
|
||||
@@ -332,6 +346,75 @@ pub(crate) fn destination_record(payload: &Path) -> PathBuf {
|
||||
))
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Where the thumbnails made with a staged payload wait for its upload:
|
||||
/// `name.ext.thumbs` beside it.
|
||||
///
|
||||
/// Not a record: [`pending`] reads only `.dest` files, so this never goes to
|
||||
/// the server. The upload reads it once the server has named the file
|
||||
/// ([`take_thumbnails`]) and puts its contents in the thumbnail store under
|
||||
/// that name; until then there is no key to store them under.
|
||||
pub(crate) fn thumbnails_record(payload: &Path) -> PathBuf {
|
||||
let mut name = payload.as_os_str().to_owned();
|
||||
name.push(".thumbs");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// The magic line a thumbnails record starts with.
|
||||
const THUMBS_MAGIC: &[u8] = b"darkroom-thumbs 1\n";
|
||||
|
||||
/// Write the thumbnails for a staged payload. A size-class byte and three
|
||||
/// little-endian `u32`s — width, height, length — before each JPEG.
|
||||
pub(crate) fn write_thumbnails(
|
||||
payload: &Path,
|
||||
thumbnails: &[(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)],
|
||||
) -> std::io::Result<()> {
|
||||
let mut out = THUMBS_MAGIC.to_vec();
|
||||
for (size, t) in thumbnails {
|
||||
out.push(*size as i64 as u8);
|
||||
for v in [t.width, t.height, t.bytes.len() as u32] {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&t.bytes);
|
||||
}
|
||||
std::fs::write(thumbnails_record(payload), out)
|
||||
}
|
||||
|
||||
/// Read and remove the thumbnails waiting beside a payload. Nothing, for a
|
||||
/// payload that has none or a record that does not parse — a composite whose
|
||||
/// thumbnails are lost is thumbnailed the ordinary way.
|
||||
pub(crate) fn take_thumbnails(payload: &Path) -> Vec<(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)> {
|
||||
let path = thumbnails_record(payload);
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
parse_thumbnails(&bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_thumbnails(bytes: &[u8]) -> Option<Vec<(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)>> {
|
||||
let mut rest = bytes.strip_prefix(THUMBS_MAGIC)?;
|
||||
let mut out = Vec::new();
|
||||
while let Some((&class, tail)) = rest.split_first() {
|
||||
let word = |at: usize| -> Option<u32> {
|
||||
Some(u32::from_le_bytes(tail.get(at..at + 4)?.try_into().ok()?))
|
||||
};
|
||||
let (width, height, len) = (word(0)?, word(4)?, word(8)? as usize);
|
||||
let data = tail.get(12..12 + len)?;
|
||||
let size = dr_thumbs::ThumbSize::from_stored(i64::from(class))?;
|
||||
out.push((
|
||||
size,
|
||||
dr_thumbs::Thumbnail {
|
||||
width,
|
||||
height,
|
||||
bytes: data.to_vec(),
|
||||
},
|
||||
));
|
||||
rest = &tail[12 + len..];
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Everything currently waiting in the outbox.
|
||||
///
|
||||
/// A payload with no record is skipped rather than guessed at — see the write
|
||||
@@ -381,11 +464,12 @@ pub fn pending_count(outbox: &Path) -> usize {
|
||||
pending(outbox).len()
|
||||
}
|
||||
|
||||
/// Remove an entry and its record, once it is safely on the server.
|
||||
/// Remove an entry and its records, once it is safely on the server.
|
||||
fn clear(entry: &Pending) {
|
||||
let record = PathBuf::from(format!("{}.dest", entry.local.display()));
|
||||
let _ = std::fs::remove_file(&entry.local);
|
||||
let _ = std::fs::remove_file(&record);
|
||||
let _ = std::fs::remove_file(thumbnails_record(&entry.local));
|
||||
}
|
||||
|
||||
/// Progress from the upload worker.
|
||||
@@ -393,13 +477,173 @@ fn clear(entry: &Pending) {
|
||||
pub enum UploadMessage {
|
||||
Status(String),
|
||||
/// Uploaded, still pending, and the first error if there was one.
|
||||
/// `landed` counts the uploads that went into the library itself rather
|
||||
/// than an album outside it: those are what a scan has something to
|
||||
/// find.
|
||||
Finished {
|
||||
uploaded: usize,
|
||||
remaining: usize,
|
||||
error: Option<String>,
|
||||
landed: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Log a drain as it goes and, once files have landed in the library, ask
|
||||
/// the grid to rescan — the scan that finds them, run after the upload
|
||||
/// rather than beside it (FR-MRG-6).
|
||||
///
|
||||
/// On a thread of its own, since the drain reports only when it finishes and
|
||||
/// that can be minutes for a composite; the window is reached through the
|
||||
/// event loop.
|
||||
pub(crate) fn watch_upload(rx: Receiver<UploadMessage>, window: slint::Weak<AppWindow>) {
|
||||
executors::spawn(Executor::Io, "upload-log", move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
match msg {
|
||||
UploadMessage::Status(s) => log::info!("export: {s}"),
|
||||
UploadMessage::Finished {
|
||||
uploaded,
|
||||
remaining,
|
||||
error,
|
||||
landed,
|
||||
} => {
|
||||
log::info!("export: {uploaded} uploaded, {remaining} still queued");
|
||||
if let Some(e) = error {
|
||||
log::warn!("export upload stopped: {e}");
|
||||
}
|
||||
if landed > 0 {
|
||||
let _ = window.upgrade_in_event_loop(|w| {
|
||||
w.global::<Library>().invoke_library_rescan();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Where a library's catalog and thumbnail store are, for the upload to
|
||||
/// record what the server made of a file.
|
||||
pub(crate) struct LibraryFiles {
|
||||
pub catalog: PathBuf,
|
||||
pub thumbs: PathBuf,
|
||||
}
|
||||
|
||||
impl LibraryFiles {
|
||||
fn of(account: &Account) -> Self {
|
||||
LibraryFiles {
|
||||
catalog: crate::library::catalog_path(account),
|
||||
thumbs: crate::library::thumbs_dir(account),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of one outbox entry.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Sent {
|
||||
Uploaded,
|
||||
/// Its payload had gone; the record went with it.
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// Send one outbox entry, and clear it once it is on the server.
|
||||
async fn send(
|
||||
backend: &dyn dr_sync::RemoteBackend,
|
||||
library: &LibraryFiles,
|
||||
root: &str,
|
||||
entry: &Pending,
|
||||
) -> Result<Sent, String> {
|
||||
let Ok(bytes) = std::fs::read(&entry.local) else {
|
||||
// The payload vanished under us. Drop the record too; retrying
|
||||
// forever against a file that is gone helps nobody.
|
||||
clear(entry);
|
||||
return Ok(Sent::Gone);
|
||||
};
|
||||
|
||||
// The folder may not exist — this is the first export into it — and
|
||||
// `create_dir` treats "already there" as success, so it is unconditional
|
||||
// rather than guarded by a check that would cost a request every time.
|
||||
backend
|
||||
.create_dir(&entry.remote_folder(root))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
backend
|
||||
.put(&entry.remote_path(root), bytes, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let thumbnails = take_thumbnails(&entry.local);
|
||||
register_upload(backend, library, root, entry, thumbnails).await;
|
||||
clear(entry);
|
||||
Ok(Sent::Uploaded)
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Tell the catalog what the server made of a file it has just been given.
|
||||
///
|
||||
/// Only for a file the catalog already has a row for — a composite the merge
|
||||
/// catalogued before its upload. Anything else is the scan's to find, and
|
||||
/// this spends no request on it. The listing is one request for the folder,
|
||||
/// and it is the only way to learn the id a server assigns on upload: the
|
||||
/// thumbnail store keys on it, and the thumbnails made during the merge wait
|
||||
/// beside the payload until it is known.
|
||||
async fn register_upload(
|
||||
backend: &dyn dr_sync::RemoteBackend,
|
||||
library: &LibraryFiles,
|
||||
root: &str,
|
||||
entry: &Pending,
|
||||
thumbnails: Vec<(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)>,
|
||||
) {
|
||||
if entry.account {
|
||||
return;
|
||||
}
|
||||
let path = entry.remote_path(root);
|
||||
let catalog = match dr_catalog::Catalog::open(&library.catalog) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("upload: opening the catalog to record {}: {e}", entry.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(image) = crate::library::image_at(&catalog, root, path.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let listed = match backend.list(&entry.remote_folder(root), None).await {
|
||||
Ok(entries) => entries.into_iter().find(|e| e.path == path),
|
||||
Err(e) => {
|
||||
log::warn!("upload: listing {} after sending it: {e}", entry.name);
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(listed) = listed else {
|
||||
log::warn!("upload: {} is not listed where it was sent", entry.name);
|
||||
return;
|
||||
};
|
||||
if let Err(e) = crate::library::record_uploaded(&catalog, image, &listed) {
|
||||
log::warn!("upload: recording {}: {e}", entry.name);
|
||||
}
|
||||
let dr_sync::RemoteId::Stable(file_id) = listed.id else {
|
||||
return;
|
||||
};
|
||||
if thumbnails.is_empty() {
|
||||
return;
|
||||
}
|
||||
match dr_thumbs::ThumbStore::open(&library.thumbs) {
|
||||
Ok(mut store) => {
|
||||
for (size, thumb) in &thumbnails {
|
||||
crate::library::store_thumbnail(&mut store, file_id, *size, thumb);
|
||||
}
|
||||
log::info!(
|
||||
"upload: {} thumbnail(s) of {} stored under file {file_id}",
|
||||
thumbnails.len(),
|
||||
entry.name
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!("upload: opening the thumbnail store: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Held by whichever drain is sending the outbox. See [`spawn_upload`].
|
||||
static OUTBOX_DRAIN: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Drain the outbox to the server.
|
||||
///
|
||||
/// Its own thread with its own runtime, like every other network path here —
|
||||
@@ -416,6 +660,16 @@ pub fn spawn_upload(
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
executors::spawn(Executor::Network, "upload", move || {
|
||||
// One drain at a time. Three things start one — a sync pass, an
|
||||
// export, a finished merge — and they used to run side by side over
|
||||
// the same queue: each read the same 800 MB composite into memory and
|
||||
// sent it, and the one that came second found its record cleared
|
||||
// under it and reported the file missing. Waiting here costs nothing:
|
||||
// the drain that holds the lock is already sending everything queued,
|
||||
// and the one that waited finds the queue empty and finishes.
|
||||
let _draining = OUTBOX_DRAIN
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let rt = match crate::net_runtime::build() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
@@ -423,6 +677,7 @@ pub fn spawn_upload(
|
||||
uploaded: 0,
|
||||
remaining: pending_count(&outbox),
|
||||
error: Some(e.to_string()),
|
||||
landed: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -436,14 +691,17 @@ pub fn spawn_upload(
|
||||
uploaded: 0,
|
||||
remaining: pending_count(&outbox),
|
||||
error: Some(e.to_string()),
|
||||
landed: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let library = LibraryFiles::of(&conn.account);
|
||||
let queue = pending(&outbox);
|
||||
let total = queue.len();
|
||||
let mut uploaded = 0;
|
||||
let mut landed = 0;
|
||||
let mut error = None;
|
||||
|
||||
for (i, entry) in queue.iter().enumerate() {
|
||||
@@ -453,30 +711,16 @@ pub fn spawn_upload(
|
||||
i + 1
|
||||
)));
|
||||
|
||||
let Ok(bytes) = std::fs::read(&entry.local) else {
|
||||
// The payload vanished under us. Drop the record too;
|
||||
// retrying forever against a file that is gone helps
|
||||
// nobody.
|
||||
clear(entry);
|
||||
continue;
|
||||
};
|
||||
|
||||
// The folder may not exist — this is the first export into it
|
||||
// — and `create_dir` treats "already there" as success, so it
|
||||
// is unconditional rather than guarded by a check that would
|
||||
// cost a request every time.
|
||||
if let Err(e) = backend.create_dir(&entry.remote_folder(&root)).await {
|
||||
error = Some(e.to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
match backend.put(&entry.remote_path(&root), bytes, None).await {
|
||||
Ok(_) => {
|
||||
clear(entry);
|
||||
match send(&*backend, &library, &root, entry).await {
|
||||
Ok(Sent::Gone) => {}
|
||||
Ok(Sent::Uploaded) => {
|
||||
uploaded += 1;
|
||||
if !entry.account {
|
||||
landed += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error = Some(e.to_string());
|
||||
error = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -486,6 +730,7 @@ pub fn spawn_upload(
|
||||
uploaded,
|
||||
remaining: pending_count(&outbox),
|
||||
error,
|
||||
landed,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1282,6 +1527,328 @@ fn stop_timer(slot: &Rc<std::cell::RefCell<Option<slint::Timer>>>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A server that names a file when it is given one, as Nextcloud does:
|
||||
/// the id exists only once the upload has landed, and a listing is the
|
||||
/// way to learn it.
|
||||
#[derive(Default)]
|
||||
struct Assigning {
|
||||
files: std::sync::Mutex<std::collections::BTreeMap<String, (u64, Vec<u8>)>>,
|
||||
lists: std::sync::atomic::AtomicUsize,
|
||||
caps: std::sync::OnceLock<dr_sync::Capabilities>,
|
||||
}
|
||||
|
||||
impl Assigning {
|
||||
fn listing(&self, dir: &str) -> Vec<dr_sync::RemoteEntry> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(p, _)| p.rsplit_once('/').map_or("", |(d, _)| d) == dir)
|
||||
.map(|(p, (id, body))| dr_sync::RemoteEntry {
|
||||
id: dr_sync::RemoteId::Stable(*id),
|
||||
path: RemotePath::new(p.clone()),
|
||||
kind: dr_sync::EntryKind::File,
|
||||
validator: dr_sync::Validator::new(format!("etag-{id}")),
|
||||
size: body.len() as u64,
|
||||
modified: None,
|
||||
has_preview: false,
|
||||
materialised: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl dr_sync::RemoteBackend for Assigning {
|
||||
fn capabilities(&self) -> &dr_sync::Capabilities {
|
||||
self.caps.get_or_init(dr_sync::Capabilities::minimal)
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"assigning"
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
dir: &RemotePath,
|
||||
_since: Option<&dr_sync::Validator>,
|
||||
) -> Result<Vec<dr_sync::RemoteEntry>, dr_sync::RemoteError> {
|
||||
self.lists.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(self.listing(dir.as_str()))
|
||||
}
|
||||
async fn dir_validator(
|
||||
&self,
|
||||
_dir: &RemotePath,
|
||||
) -> Result<dr_sync::Validator, dr_sync::RemoteError> {
|
||||
Err(dr_sync::RemoteError::Unsupported("test"))
|
||||
}
|
||||
async fn delta(
|
||||
&self,
|
||||
_c: &dr_sync::Cursor,
|
||||
) -> Result<(Vec<dr_sync::RemoteChange>, dr_sync::Cursor), dr_sync::RemoteError> {
|
||||
Err(dr_sync::RemoteError::Unsupported("test"))
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_id: &dr_sync::RemoteId,
|
||||
_r: Option<std::ops::Range<u64>>,
|
||||
) -> Result<Vec<u8>, dr_sync::RemoteError> {
|
||||
Err(dr_sync::RemoteError::Unsupported("test"))
|
||||
}
|
||||
async fn put(
|
||||
&self,
|
||||
path: &RemotePath,
|
||||
body: Vec<u8>,
|
||||
_pc: Option<dr_sync::Precondition>,
|
||||
) -> Result<dr_sync::Validator, dr_sync::RemoteError> {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let id = files
|
||||
.get(path.as_str())
|
||||
.map(|(id, _)| *id)
|
||||
.unwrap_or(40_000 + files.len() as u64);
|
||||
files.insert(path.as_str().to_string(), (id, body));
|
||||
Ok(dr_sync::Validator::new(format!("etag-{id}")))
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_id: &dr_sync::RemoteId,
|
||||
_pc: Option<dr_sync::Precondition>,
|
||||
) -> Result<(), dr_sync::RemoteError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn move_to(
|
||||
&self,
|
||||
_f: &dr_sync::RemoteId,
|
||||
_t: &RemotePath,
|
||||
) -> Result<(), dr_sync::RemoteError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn create_dir(&self, _p: &RemotePath) -> Result<(), dr_sync::RemoteError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A library of one sweep, its catalog on disk, a store beside it, and
|
||||
/// an outbox holding the composite the merge staged: the row written
|
||||
/// ahead of the scan, the payload, its record, and its thumbnails.
|
||||
struct Staged {
|
||||
dir: PathBuf,
|
||||
library: LibraryFiles,
|
||||
entry: Pending,
|
||||
image: i64,
|
||||
}
|
||||
|
||||
fn staged(name: &str, root: &str) -> Staged {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("dr-composite-upload-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("outbox")).unwrap();
|
||||
let library = LibraryFiles {
|
||||
catalog: dir.join("catalog.sqlite"),
|
||||
thumbs: dir.join("thumbs"),
|
||||
};
|
||||
let at = |n: &str| {
|
||||
if root.is_empty() {
|
||||
format!("Alps/{n}")
|
||||
} else {
|
||||
format!("{root}/Alps/{n}")
|
||||
}
|
||||
};
|
||||
let catalog = dr_catalog::Catalog::open(&library.catalog).unwrap();
|
||||
crate::library::test_support::scanned_listing(
|
||||
&catalog,
|
||||
root,
|
||||
vec![crate::library::test_support::entry(
|
||||
&at("_MG_8320.CR2"),
|
||||
1,
|
||||
30,
|
||||
)],
|
||||
);
|
||||
let image = crate::library::catalogue_composite(
|
||||
&catalog,
|
||||
root,
|
||||
&crate::library::CompositeRow {
|
||||
source_ref: staged_remote_path(root, "Alps", "_MG_8320-pano.dng")
|
||||
.as_str()
|
||||
.to_string(),
|
||||
width: 4000,
|
||||
height: 1000,
|
||||
captured_at: Some(1_000),
|
||||
captured_offset: None,
|
||||
camera: None,
|
||||
lens: None,
|
||||
iso: None,
|
||||
file_size: 5,
|
||||
sources: vec![at("_MG_8320.CR2")],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// The scan that ran while the upload was still going: the folder is
|
||||
// listed without the composite, and the row is still there after it.
|
||||
crate::library::test_support::scanned_listing(
|
||||
&catalog,
|
||||
root,
|
||||
vec![crate::library::test_support::entry(
|
||||
&at("_MG_8320.CR2"),
|
||||
1,
|
||||
30,
|
||||
)],
|
||||
);
|
||||
let local = dir.join("outbox").join("_MG_8320-pano.dng");
|
||||
std::fs::write(&local, b"pano!").unwrap();
|
||||
std::fs::write(destination_record(&local), "Alps\n_MG_8320-pano.dng\n").unwrap();
|
||||
let jpeg = dr_thumbs::encode_rgba(4, 1, &[200u8; 16]).unwrap();
|
||||
write_thumbnails(
|
||||
&local,
|
||||
&[
|
||||
(
|
||||
dr_thumbs::ThumbSize::Grid,
|
||||
dr_thumbs::Thumbnail {
|
||||
width: 4,
|
||||
height: 1,
|
||||
bytes: jpeg.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
dr_thumbs::ThumbSize::Large,
|
||||
dr_thumbs::Thumbnail {
|
||||
width: 4,
|
||||
height: 1,
|
||||
bytes: jpeg,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let entry = pending(&dir.join("outbox")).pop().unwrap();
|
||||
Staged {
|
||||
dir,
|
||||
library,
|
||||
entry,
|
||||
image,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_id_of(library: &LibraryFiles, image: i64) -> Option<i64> {
|
||||
dr_catalog::Catalog::open(&library.catalog)
|
||||
.unwrap()
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT file_id FROM remote WHERE image_id = ?1",
|
||||
[image],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_upload_that_lands_after_the_scan_gives_the_row_its_server_id() {
|
||||
let s = staged("nextcloud", "PhotosRaw");
|
||||
let server = Assigning::default();
|
||||
assert_eq!(file_id_of(&s.library, s.image), None);
|
||||
|
||||
let sent = send(&server, &s.library, "PhotosRaw", &s.entry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sent, Sent::Uploaded);
|
||||
|
||||
// The id the server assigned, on the row the merge wrote, and the
|
||||
// merge's thumbnails in the store under it.
|
||||
let listed = server.listing("PhotosRaw/Alps");
|
||||
let dr_sync::RemoteId::Stable(id) = listed[0].id else {
|
||||
panic!("the server names its files");
|
||||
};
|
||||
assert_eq!(file_id_of(&s.library, s.image), Some(id as i64));
|
||||
let store = dr_thumbs::ThumbStore::open(&s.library.thumbs).unwrap();
|
||||
assert!(store.contains(id, dr_thumbs::ThumbSize::Grid));
|
||||
assert!(store.contains(id, dr_thumbs::ThumbSize::Large));
|
||||
// Nothing left in the outbox: not the payload, not its records.
|
||||
assert_eq!(
|
||||
std::fs::read_dir(s.dir.join("outbox")).unwrap().count(),
|
||||
0,
|
||||
"the outbox is empty"
|
||||
);
|
||||
|
||||
// The scan that follows the upload finds the row already there.
|
||||
let catalog = dr_catalog::Catalog::open(&s.library.catalog).unwrap();
|
||||
crate::library::test_support::scanned_listing(&catalog, "PhotosRaw", listed);
|
||||
let n: i64 = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT count(*) FROM images WHERE source_ref LIKE '%pano%'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_folder_library_learns_the_id_the_folder_gives_its_path() {
|
||||
let s = staged("folder", "");
|
||||
let library_dir = s.dir.join("library");
|
||||
std::fs::create_dir_all(library_dir.join("Alps")).unwrap();
|
||||
let folder = dr_sync_folder::FolderBackend::new(&library_dir).unwrap();
|
||||
|
||||
send(&folder, &s.library, "", &s.entry).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(library_dir.join("Alps/_MG_8320-pano.dng")).unwrap(),
|
||||
b"pano!"
|
||||
);
|
||||
use dr_sync::RemoteBackend as _;
|
||||
let listed = folder
|
||||
.list(&RemotePath::new("Alps"), None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|e| e.path.name() == "_MG_8320-pano.dng")
|
||||
.unwrap();
|
||||
let dr_sync::RemoteId::Stable(id) = listed.id else {
|
||||
panic!("a folder names a file by its path");
|
||||
};
|
||||
assert_eq!(file_id_of(&s.library, s.image), Some(id as i64));
|
||||
let store = dr_thumbs::ThumbStore::open(&s.library.thumbs).unwrap();
|
||||
assert!(store.contains(id, dr_thumbs::ThumbSize::Grid));
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ordinary_export_costs_no_listing() {
|
||||
let s = staged("export", "PhotosRaw");
|
||||
let other = s.dir.join("outbox").join("print.jpg");
|
||||
std::fs::write(&other, b"jpeg").unwrap();
|
||||
std::fs::write(destination_record(&other), "Alps\nprint.jpg\n").unwrap();
|
||||
let entry = pending(&s.dir.join("outbox"))
|
||||
.into_iter()
|
||||
.find(|p| p.name == "print.jpg")
|
||||
.unwrap();
|
||||
let server = Assigning::default();
|
||||
send(&server, &s.library, "PhotosRaw", &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(server.lists.load(Ordering::SeqCst), 0);
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_survive_the_outbox_and_a_damaged_record_is_nothing() {
|
||||
let dir = tmp();
|
||||
let payload = dir.join("x.dng");
|
||||
let t = dr_thumbs::Thumbnail {
|
||||
width: 3,
|
||||
height: 2,
|
||||
bytes: vec![1, 2, 3, 4, 5],
|
||||
};
|
||||
write_thumbnails(&payload, &[(dr_thumbs::ThumbSize::Large, t.clone())]).unwrap();
|
||||
assert_eq!(
|
||||
take_thumbnails(&payload),
|
||||
vec![(dr_thumbs::ThumbSize::Large, t)]
|
||||
);
|
||||
assert!(!thumbnails_record(&payload).exists(), "taken, not copied");
|
||||
std::fs::write(thumbnails_record(&payload), b"darkroom-thumbs 1\n\x00\x01").unwrap();
|
||||
assert!(take_thumbnails(&payload).is_empty());
|
||||
}
|
||||
|
||||
fn encoded(name: &str, bytes: &[u8]) -> Encoded {
|
||||
Encoded {
|
||||
name: name.to_string(),
|
||||
|
||||
+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>,
|
||||
|
||||
+24
-24
@@ -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;
|
||||
@@ -731,7 +733,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 +749,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.
|
||||
@@ -1901,17 +1887,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());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+64
-17
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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