Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064be89a73 | ||
|
|
bff12f81a9 | ||
|
|
07e85cf0b2 | ||
|
|
b23527d5dc | ||
|
|
3b783d7bd4 |
Generated
+25
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
+13
-1
@@ -32,7 +32,7 @@ members = [
|
|||||||
exclude = ["third_party"]
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.19.3"
|
version = "0.19.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
@@ -276,6 +276,18 @@ opt-level = 0
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
|
# A release build that can say where it panicked: line tables, so a crash
|
||||||
|
# record's backtrace (`dr_plat::crash`) reads `file.rs:123` rather than bare
|
||||||
|
# addresses. The macOS build uses it (docs/dev/macos.md) — no one here can
|
||||||
|
# reproduce a Mac bug, so its reports carry what a debugger would have — at
|
||||||
|
# the price of a larger binary and no slower code. On macOS the tables land
|
||||||
|
# in a `.dSYM` beside the executable (rustc's default `packed`), and the
|
||||||
|
# bundle must carry that directory next to the binary for the backtrace to
|
||||||
|
# find it.
|
||||||
|
[profile.diagnostic]
|
||||||
|
inherits = "release"
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
||||||
# renderer so that the Android build can draw with wgpu on a rotated display
|
# renderer so that the Android build can draw with wgpu on a rotated display
|
||||||
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ controls, its place in the chain and its tests.
|
|||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.19.3**, thirty-two tagged releases in. 193 numbered requirements in
|
**0.19.2**, thirty-one tagged releases in. 193 numbered requirements in
|
||||||
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||||
the rest are written down rather than merely absent.
|
the rest are written down rather than merely absent.
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
|
|
||||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||||
here is a distribution manifest yet. The library grid needs no storage
|
here is a distribution manifest yet. Only network access is declared: file
|
||||||
permission, because it reads through SAF, which grants per-tree at runtime
|
access needs no manifest permission because the library grid reads through
|
||||||
(ARCH §6.9); the one storage permission declared is for importing from a
|
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||||
camera card, which is read by path.
|
|
||||||
|
|
||||||
Minimal is not the same as empty, and the entries below that are not the
|
Minimal is not the same as empty, and the entries below that are not the
|
||||||
activity are the difference. A manifest is the only place a component can be
|
activity are the difference. A manifest is the only place a component can be
|
||||||
@@ -22,29 +21,13 @@
|
|||||||
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||||
socket creation outright, and the failure is invisible — no panic to
|
socket creation outright, and the failure is invisible — no panic to
|
||||||
catch, no log line, just a worker thread that stops. Storage is the
|
catch, no log line, just a worker thread that stops. Storage is the
|
||||||
separate case: the library and album folders need no permission
|
separate case that genuinely needs no permission here, because SAF
|
||||||
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
|
grants per-tree at runtime (ARCH §6.9). -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||||
work on unmetered-and-charging, which means knowing the network type. -->
|
work on unmetered-and-charging, which means knowing the network type. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
|
|
||||||
as files, and "all files access" is what makes an SD card or a USB
|
|
||||||
card reader readable by path on API 30 and up (see Cards.java). It is
|
|
||||||
granted on a system settings page, not a dialog; the import page
|
|
||||||
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
|
|
||||||
same thing for API 28 and 29, and means nothing above them; on 29 it
|
|
||||||
reads by path only with requestLegacyExternalStorage, which is why
|
|
||||||
<application> carries that flag.
|
|
||||||
|
|
||||||
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
|
|
||||||
kinds. DarkRoom is not distributed through Play. -->
|
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="29" />
|
|
||||||
|
|
||||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||||
fails at install rather than at first frame. -->
|
fails at install rather than at first frame. -->
|
||||||
@@ -70,7 +53,6 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:hasCode="true"
|
android:hasCode="true"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:requestLegacyExternalStorage="true"
|
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
|
|
||||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,22 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use dr_plat::diagnostics::Installed;
|
use dr_plat::diagnostics::Installed;
|
||||||
|
|
||||||
|
/// What the log keeps when `RUST_LOG` does not say.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const DEFAULT_LOG: &str =
|
||||||
|
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn";
|
||||||
|
|
||||||
|
/// The same, and `debug` from this application's own crates and from ONNX
|
||||||
|
/// Runtime, whose `debug` is how many nodes each provider took
|
||||||
|
/// (docs/dev/macos.md). Nobody here runs a Mac: every macOS build is in
|
||||||
|
/// the hands of someone who can send us a log and cannot attach a debugger,
|
||||||
|
/// so the log is written as if for a debug build. `dr_` is a prefix, and
|
||||||
|
/// `env_logger` matches directives by prefix, so it names every `dr-*`
|
||||||
|
/// crate — present and future — without naming a dependency.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const DEFAULT_LOG: &str = "info,dr_=debug,darkroom_desktop=debug,onnxruntime=debug,\
|
||||||
|
wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn";
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
// TRACES: FR-PLAT-WIN-3
|
// TRACES: FR-PLAT-WIN-3
|
||||||
// Before the logger, the crash hook and everything else: this exists so a
|
// Before the logger, the crash hook and everything else: this exists so a
|
||||||
@@ -33,10 +49,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may
|
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may
|
||||||
// have overridden the default below, and the file must not be quieter than
|
// have overridden the default below, and the file must not be quieter than
|
||||||
// the terminal.
|
// the terminal.
|
||||||
let console = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
let console =
|
||||||
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(DEFAULT_LOG))
|
||||||
))
|
.build();
|
||||||
.build();
|
|
||||||
let level = console.filter();
|
let level = console.filter();
|
||||||
let logging = dr_plat::diagnostics::install(Box::new(console), level);
|
let logging = dr_plat::diagnostics::install(Box::new(console), level);
|
||||||
|
|
||||||
@@ -109,5 +124,21 @@ fn runtime_dirs() -> Vec<PathBuf> {
|
|||||||
PathBuf::from("/usr/lib/darkroom"),
|
PathBuf::from("/usr/lib/darkroom"),
|
||||||
PathBuf::from("/usr/lib"),
|
PathBuf::from("/usr/lib"),
|
||||||
]);
|
]);
|
||||||
|
// An app bundle keeps its libraries in `Contents/Frameworks`, beside
|
||||||
|
// the `Contents/MacOS` the executable is in; then Homebrew's
|
||||||
|
// `onnxruntime`, Apple silicon's prefix before Intel's. Homebrew's build
|
||||||
|
// may lack CoreML, which the probe finds out for itself.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin) = exe.parent() {
|
||||||
|
dirs.push(bin.join("../Frameworks"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.extend([
|
||||||
|
PathBuf::from("/opt/homebrew/lib"),
|
||||||
|
PathBuf::from("/usr/local/lib"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
dirs
|
dirs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
|
use dr_catalog::{keywords, rating, schema, Catalog};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
@@ -108,9 +108,6 @@ fn main() {
|
|||||||
time(" keywords::adopt_orphan_terms", 20, || {
|
time(" keywords::adopt_orphan_terms", 20, || {
|
||||||
keywords::adopt_orphan_terms(conn).unwrap();
|
keywords::adopt_orphan_terms(conn).unwrap();
|
||||||
});
|
});
|
||||||
time(" name_dates::fill", 20, || {
|
|
||||||
name_dates::fill(conn, None).unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
interactive(conn);
|
interactive(conn);
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pub mod faces;
|
|||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod keywords;
|
pub mod keywords;
|
||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod name_dates;
|
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod rating;
|
pub mod rating;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
//! 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,15 +356,6 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
|||||||
out.push(("keyword_terms", n));
|
out.push(("keyword_terms", n));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: FR-CAT-5
|
|
||||||
// A date from the file's name for every examined image EXIF left undated.
|
|
||||||
// The sweep does this as it examines each image; this is for the images
|
|
||||||
// examined by a build that did not, and reads the undated side alone.
|
|
||||||
let n = crate::name_dates::fill(conn, None)?;
|
|
||||||
if n > 0 {
|
|
||||||
out.push(("dates_from_names", n));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-109
@@ -30,27 +30,18 @@
|
|||||||
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
||||||
//! as it is needed, and a caller short of memory may demosaic on demand.
|
//! as it is needed, and a caller short of memory may demosaic on demand.
|
||||||
//!
|
//!
|
||||||
//! # The blend
|
|
||||||
//!
|
|
||||||
//! With a seam map (`dr_pano::seam`), a frame's weight at a pixel is its
|
|
||||||
//! share of the map about that pixel — whole on its own side of a seam,
|
|
||||||
//! nothing on the other, and a ramp across a window `seam_blend` pixels
|
|
||||||
//! wide that follows the seam. Without one, or where the map has nothing
|
|
||||||
//! to say, the weight is the distance to the frame's edge over `feather`,
|
|
||||||
//! which hides exposure steps and does not hide parallax: the average draws
|
|
||||||
//! anything the frames disagree on twice.
|
|
||||||
//!
|
|
||||||
//! # What is not here yet
|
//! # What is not here yet
|
||||||
//!
|
//!
|
||||||
//! A Laplacian pyramid, which would let the seam's blend be narrow for
|
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
|
||||||
//! detail and wide for exposure at once. Gain is a scalar per frame the
|
//! distance to the frame's edge, which hides exposure steps and small
|
||||||
//! caller supplies.
|
//! 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.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use dr_pano::bundle::Cameras;
|
use dr_pano::bundle::Cameras;
|
||||||
use dr_pano::projection::{Bounds, Projection};
|
use dr_pano::projection::{Bounds, Projection};
|
||||||
use dr_pano::seam::SeamMap;
|
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::readback::await_mapping;
|
use crate::readback::await_mapping;
|
||||||
@@ -67,7 +58,7 @@ pub struct MergeFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The output the merge produces.
|
/// The output the merge produces.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct MergeOutput {
|
pub struct MergeOutput {
|
||||||
pub projection: Projection,
|
pub projection: Projection,
|
||||||
/// The projection's scale in output pixels: the cylinder's radius, the
|
/// The projection's scale in output pixels: the cylinder's radius, the
|
||||||
@@ -78,11 +69,6 @@ pub struct MergeOutput {
|
|||||||
pub bounds: Bounds,
|
pub bounds: Bounds,
|
||||||
/// Pixels over which a frame's weight ramps up from its edge.
|
/// Pixels over which a frame's weight ramps up from its edge.
|
||||||
pub feather: f32,
|
pub feather: f32,
|
||||||
/// Which frame each part of the output is taken from, laid out at the
|
|
||||||
/// proxies' scale; `None` for the feathered average everywhere.
|
|
||||||
pub seams: Option<Arc<SeamMap>>,
|
|
||||||
/// The width, in output pixels, of the blend across a seam.
|
|
||||||
pub seam_blend: f32,
|
|
||||||
/// Chunk size: the unit of GPU work and of memory.
|
/// Chunk size: the unit of GPU work and of memory.
|
||||||
pub chunk: (u32, u32),
|
pub chunk: (u32, u32),
|
||||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||||
@@ -129,12 +115,6 @@ struct WarpParams {
|
|||||||
feather: f32,
|
feather: f32,
|
||||||
clip_onset: f32,
|
clip_onset: f32,
|
||||||
balance: [f32; 4],
|
balance: [f32; 4],
|
||||||
seam_origin: [f32; 2],
|
|
||||||
seam_size: [u32; 2],
|
|
||||||
seam_px: f32,
|
|
||||||
seam_radius: f32,
|
|
||||||
frame_index: u32,
|
|
||||||
seam_on: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -202,16 +182,6 @@ impl MergePass {
|
|||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
storage(2, false),
|
storage(2, false),
|
||||||
wgpu::BindGroupLayoutEntry {
|
|
||||||
binding: 3,
|
|
||||||
visibility: wgpu::ShaderStages::COMPUTE,
|
|
||||||
ty: wgpu::BindingType::Texture {
|
|
||||||
sample_type: wgpu::TextureSampleType::Uint,
|
|
||||||
view_dimension: wgpu::TextureViewDimension::D2,
|
|
||||||
multisampled: false,
|
|
||||||
},
|
|
||||||
count: None,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let resolve_layout =
|
let resolve_layout =
|
||||||
@@ -309,32 +279,6 @@ impl MergePass {
|
|||||||
let mut band_cov = vec![false; (out_w * ch) as usize];
|
let mut band_cov = vec![false; (out_w * ch) as usize];
|
||||||
let mut chunk_px: Vec<u32> = Vec::new();
|
let mut chunk_px: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
// The seam map, once for the whole output, and where it sits in
|
|
||||||
// this output's coordinates. A one-texel stand-in when there is
|
|
||||||
// none, because the binding is not optional.
|
|
||||||
let (seam_tex, seam_origin, seam_px, seam_radius, seam_size) = match &output.seams {
|
|
||||||
Some(m) => {
|
|
||||||
let ((ou, ov), px) = m.at_scale(output.scale);
|
|
||||||
let radius = m.blend_radius(output.scale, f64::from(output.seam_blend));
|
|
||||||
(
|
|
||||||
self.label_texture(m.width as u32, m.height as u32, &m.labels),
|
|
||||||
[ou as f32, ov as f32],
|
|
||||||
px as f32,
|
|
||||||
radius as f32,
|
|
||||||
[m.width as u32, m.height as u32],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
None => (
|
|
||||||
self.label_texture(1, 1, &[dr_pano::seam::NONE]),
|
|
||||||
[0.0; 2],
|
|
||||||
1.0,
|
|
||||||
1.0,
|
|
||||||
[1, 1],
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let seam_view = seam_tex.create_view(&Default::default());
|
|
||||||
let seam_on = u32::from(output.seams.is_some());
|
|
||||||
|
|
||||||
let mut y = 0u32;
|
let mut y = 0u32;
|
||||||
while y < out_h {
|
while y < out_h {
|
||||||
let rows = ch.min(out_h - y);
|
let rows = ch.min(out_h - y);
|
||||||
@@ -402,14 +346,8 @@ impl MergePass {
|
|||||||
output.balance[2].max(1e-3),
|
output.balance[2].max(1e-3),
|
||||||
0.0,
|
0.0,
|
||||||
],
|
],
|
||||||
seam_origin,
|
|
||||||
seam_size,
|
|
||||||
seam_px,
|
|
||||||
seam_radius,
|
|
||||||
frame_index: k as u32,
|
|
||||||
seam_on,
|
|
||||||
};
|
};
|
||||||
self.accumulate(¶ms, tile, &seam_view);
|
self.accumulate(¶ms, tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
||||||
@@ -447,42 +385,7 @@ impl MergePass {
|
|||||||
self.ctx.queue.submit(Some(enc.finish()));
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The seam map's labels as an `r8uint` texture.
|
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) {
|
||||||
fn label_texture(&self, width: u32, height: u32, labels: &[u8]) -> wgpu::Texture {
|
|
||||||
let size = wgpu::Extent3d {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth_or_array_layers: 1,
|
|
||||||
};
|
|
||||||
let tex = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label: Some("merge-seams"),
|
|
||||||
size,
|
|
||||||
mip_level_count: 1,
|
|
||||||
sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: wgpu::TextureFormat::R8Uint,
|
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
||||||
view_formats: &[],
|
|
||||||
});
|
|
||||||
self.ctx.queue.write_texture(
|
|
||||||
wgpu::TexelCopyTextureInfo {
|
|
||||||
texture: &tex,
|
|
||||||
mip_level: 0,
|
|
||||||
origin: wgpu::Origin3d::ZERO,
|
|
||||||
aspect: wgpu::TextureAspect::All,
|
|
||||||
},
|
|
||||||
labels,
|
|
||||||
wgpu::TexelCopyBufferLayout {
|
|
||||||
offset: 0,
|
|
||||||
bytes_per_row: Some(width),
|
|
||||||
rows_per_image: Some(height),
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
tex
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture, seams: &wgpu::TextureView) {
|
|
||||||
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
||||||
let uniforms = self
|
let uniforms = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -514,10 +417,6 @@ impl MergePass {
|
|||||||
binding: 2,
|
binding: 2,
|
||||||
resource: acc.as_entire_binding(),
|
resource: acc.as_entire_binding(),
|
||||||
},
|
},
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 3,
|
|
||||||
resource: wgpu::BindingResource::TextureView(seams),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
// pixel it asks which direction that pixel looks along, turns the
|
// pixel it asks which direction that pixel looks along, turns the
|
||||||
// direction into the frame's camera, projects it to a source pixel, and
|
// direction into the frame's camera, projects it to a source pixel, and
|
||||||
// if that pixel is inside the tile that was rendered for this chunk,
|
// if that pixel is inside the tile that was rendered for this chunk,
|
||||||
// samples it and adds it — weighted by the frame's share of the seam map
|
// samples it and adds it — weighted by its distance from the frame's edge
|
||||||
// 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
|
||||||
// into the accumulator. `resolve` runs once per chunk after every frame
|
|
||||||
// has been added: divides the sums by the weights and packs the result as
|
// has been added: divides the sums by the weights and packs the result as
|
||||||
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
||||||
//
|
//
|
||||||
@@ -51,83 +50,12 @@ struct Params {
|
|||||||
// white balance the composite will be developed with.
|
// white balance the composite will be developed with.
|
||||||
clip_onset: f32,
|
clip_onset: f32,
|
||||||
balance: vec4<f32>,
|
balance: vec4<f32>,
|
||||||
// The seam map (`dr_pano::seam`): where its texel (0, 0)'s corner sits
|
|
||||||
// in this output's centred coordinates, its size, output pixels per
|
|
||||||
// texel, the blend's radius in texels, which frame this dispatch is,
|
|
||||||
// and whether there is a map at all.
|
|
||||||
seam_origin: vec2<f32>,
|
|
||||||
seam_size: vec2<u32>,
|
|
||||||
seam_px: f32,
|
|
||||||
seam_radius: f32,
|
|
||||||
frame_index: u32,
|
|
||||||
seam_on: u32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> p: Params;
|
@group(0) @binding(0) var<uniform> p: Params;
|
||||||
@group(0) @binding(1) var tile: texture_2d<f32>;
|
@group(0) @binding(1) var tile: texture_2d<f32>;
|
||||||
// rgb·w summed, then w: four floats per chunk pixel.
|
// rgb·w summed, then w: four floats per chunk pixel.
|
||||||
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
||||||
// One frame index per texel, 255 for none.
|
|
||||||
@group(0) @binding(3) var seams: texture_2d<u32>;
|
|
||||||
|
|
||||||
const NO_FRAME: u32 = 255u;
|
|
||||||
|
|
||||||
fn label(i: i32, j: i32) -> u32 {
|
|
||||||
if (i < 0 || j < 0 || i >= i32(p.seam_size.x) || j >= i32(p.seam_size.y)) {
|
|
||||||
return NO_FRAME;
|
|
||||||
}
|
|
||||||
return textureLoad(seams, vec2<i32>(i, j), 0).r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This frame's share of the seam map about output point (u, v): the
|
|
||||||
// tent-weighted fraction of the texels within the radius that it owns, and
|
|
||||||
// the weight of the texels owned by anyone (zero where the map has nothing
|
|
||||||
// to say). `SeamMap::share` verbatim.
|
|
||||||
fn seam_share(u: f32, v: f32) -> vec2<f32> {
|
|
||||||
let x = (u - p.seam_origin.x) / p.seam_px - 0.5;
|
|
||||||
let y = (v - p.seam_origin.y) / p.seam_px - 0.5;
|
|
||||||
let r = max(p.seam_radius, 1.0);
|
|
||||||
let x0 = i32(ceil(x - r));
|
|
||||||
let x1 = i32(floor(x + r));
|
|
||||||
let y0 = i32(ceil(y - r));
|
|
||||||
let y1 = i32(floor(y + r));
|
|
||||||
// Most pixels are nowhere near a seam: if the window's corners, edge
|
|
||||||
// midpoints and centre agree, so does the window. A seam crossing it
|
|
||||||
// has to cross its border, between two of those.
|
|
||||||
let xm = i32(round(x));
|
|
||||||
let ym = i32(round(y));
|
|
||||||
let c = label(xm, ym);
|
|
||||||
if (label(x0, y0) == c && label(x1, y0) == c && label(x0, y1) == c && label(x1, y1) == c
|
|
||||||
&& label(xm, y0) == c && label(xm, y1) == c && label(x0, ym) == c && label(x1, ym) == c) {
|
|
||||||
if (c == NO_FRAME) {
|
|
||||||
return vec2<f32>(0.0, 0.0);
|
|
||||||
}
|
|
||||||
return vec2<f32>(select(0.0, 1.0, c == p.frame_index), 1.0);
|
|
||||||
}
|
|
||||||
var mine = 0.0;
|
|
||||||
var owned = 0.0;
|
|
||||||
for (var j = y0; j <= y1; j = j + 1) {
|
|
||||||
let wy = 1.0 - abs(y - f32(j)) / r;
|
|
||||||
if (wy <= 0.0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (var i = x0; i <= x1; i = i + 1) {
|
|
||||||
let wx = 1.0 - abs(x - f32(i)) / r;
|
|
||||||
let l = label(i, j);
|
|
||||||
if (wx <= 0.0 || l == NO_FRAME) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
owned = owned + wx * wy;
|
|
||||||
if (l == p.frame_index) {
|
|
||||||
mine = mine + wx * wy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (owned <= 0.0) {
|
|
||||||
return vec2<f32>(0.0, 0.0);
|
|
||||||
}
|
|
||||||
return vec2<f32>(mine / owned, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
||||||
let s = p.proj_scale;
|
let s = p.proj_scale;
|
||||||
@@ -170,17 +98,7 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
if (edge <= 0.0) {
|
if (edge <= 0.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||||
// With seams, the share of the map scales it. The small floor keeps
|
|
||||||
// the feather underneath as the answer wherever no frame that reaches
|
|
||||||
// this pixel owns it — the map is coarser than the output, so at the
|
|
||||||
// frames' outer edges it can name a frame that falls just short.
|
|
||||||
if (p.seam_on != 0u) {
|
|
||||||
let s = seam_share(u, v);
|
|
||||||
if (s.y > 0.0) {
|
|
||||||
w = w * (s.x + 1e-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Into the tile.
|
// Into the tile.
|
||||||
let tx = sx - p.tile_origin.x;
|
let tx = sx - p.tile_origin.x;
|
||||||
let ty = sy - p.tile_origin.y;
|
let ty = sy - p.tile_origin.y;
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ ort = { workspace = true, features = ["cuda", "tensorrt"] }
|
|||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
ort = { workspace = true, features = ["qnn"] }
|
ort = { workspace = true, features = ["qnn"] }
|
||||||
|
|
||||||
|
# The Apple rung: CoreML's option builder, which fills the runtime's generic
|
||||||
|
# key/value map. `ort-sys`'s `coreml` feature is empty; nothing links.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
ort = { workspace = true, features = ["coreml"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# The floor: `tract` supplies the API table when no runtime file is found, or
|
# The floor: `tract` supplies the API table when no runtime file is found, or
|
||||||
# always, in a build without `native`. Tests want this and nothing else.
|
# always, in a build without `native`. Tests want this and nothing else.
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ pub fn context_path(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
|||||||
.join(format!("{:016x}_ctx.onnx", hash(bytes)))
|
.join(format!("{:016x}_ctx.onnx", hash(bytes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where CoreML compiles `bytes` to: one directory per model, because
|
||||||
|
/// CoreML's own cache key leaves out the weights of a model loaded from
|
||||||
|
/// memory (`session::coreml`), and one per runtime version, which wrote it.
|
||||||
|
pub fn coreml_dir(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
||||||
|
let runtime = match crate::api::runtime() {
|
||||||
|
crate::Runtime::OnnxRuntime { version, .. } => version,
|
||||||
|
crate::Runtime::Tract => "tract".into(),
|
||||||
|
};
|
||||||
|
cfg.cache_dir
|
||||||
|
.join("coreml")
|
||||||
|
.join(runtime)
|
||||||
|
.join(format!("{:016x}", hash(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
/// After the probe: compile every configured model the selected rung can
|
/// After the probe: compile every configured model the selected rung can
|
||||||
/// take, smallest first, recording each as it lands.
|
/// take, smallest first, recording each as it lands.
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -90,12 +104,27 @@ pub fn run() {
|
|||||||
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
|
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
|
||||||
};
|
};
|
||||||
let key = key(rung, &bytes);
|
let key = key(rung, &bytes);
|
||||||
if state().lock().unwrap().cache.compiled.contains(&key) {
|
{
|
||||||
continue;
|
let s = state().lock().unwrap();
|
||||||
|
if s.cache.compiled.contains(&key) || s.cache.refused.contains(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log::info!("inference: compiling {name} for {}", rung.label());
|
log::info!("inference: compiling {name} for {}", rung.label());
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
match crate::session::build(rung, role, &bytes, &cfg) {
|
let built = match crate::probe::attempt(&cfg, &key, || {
|
||||||
|
crate::session::build(rung, role, &bytes, &cfg)
|
||||||
|
}) {
|
||||||
|
Ok(built) => built,
|
||||||
|
Err(_) => {
|
||||||
|
// Refused: the process died inside this compile before.
|
||||||
|
let mut s = state().lock().unwrap();
|
||||||
|
s.cache.refused.insert(key);
|
||||||
|
crate::probe::write_cache(&s.config, &s.cache);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match built {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
drop(session);
|
drop(session);
|
||||||
let mut s = state().lock().unwrap();
|
let mut s = state().lock().unwrap();
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ pub enum Rung {
|
|||||||
MiGraphX,
|
MiGraphX,
|
||||||
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
||||||
Hexagon,
|
Hexagon,
|
||||||
|
/// Apple, through CoreML: the Neural Engine, the GPU or the CPU, as
|
||||||
|
/// CoreML schedules it. macOS only. Compiles an ML Program per model on
|
||||||
|
/// first use, so it is a compiling rung with the CPU below it. The
|
||||||
|
/// embedder stays on the CPU, as on the Hexagon: the Neural Engine
|
||||||
|
/// computes in fp16 (§7).
|
||||||
|
CoreMl,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rung {
|
impl Rung {
|
||||||
@@ -88,6 +94,7 @@ impl Rung {
|
|||||||
Rung::TensorRt => "TensorRT",
|
Rung::TensorRt => "TensorRT",
|
||||||
Rung::MiGraphX => "MIGraphX",
|
Rung::MiGraphX => "MIGraphX",
|
||||||
Rung::Hexagon => "Hexagon NPU",
|
Rung::Hexagon => "Hexagon NPU",
|
||||||
|
Rung::CoreMl => "CoreML",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,13 +103,16 @@ impl Rung {
|
|||||||
fn fallback(self) -> Rung {
|
fn fallback(self) -> Rung {
|
||||||
match self {
|
match self {
|
||||||
Rung::TensorRt => Rung::Cuda,
|
Rung::TensorRt => Rung::Cuda,
|
||||||
Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a session on this rung needs an engine built first.
|
/// Whether a session on this rung needs an engine built first.
|
||||||
fn compiles(self) -> bool {
|
fn compiles(self) -> bool {
|
||||||
matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon)
|
matches!(
|
||||||
|
self,
|
||||||
|
Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The model form this rung wants for a role.
|
/// The model form this rung wants for a role.
|
||||||
@@ -116,9 +126,11 @@ impl Rung {
|
|||||||
/// Whether this rung runs `role` at all. The Hexagon takes int8 graphs
|
/// Whether this rung runs `role` at all. The Hexagon takes int8 graphs
|
||||||
/// only, and the embedder is never int8 (§7) — it runs on the CPU
|
/// only, and the embedder is never int8 (§7) — it runs on the CPU
|
||||||
/// beside a detector on the NPU, so its vectors compare across devices.
|
/// beside a detector on the NPU, so its vectors compare across devices.
|
||||||
|
/// CoreML is kept off the embedder for the same reason: the Neural
|
||||||
|
/// Engine is fp16, and which unit runs a graph is CoreML's choice.
|
||||||
fn serves(self, role: Role) -> bool {
|
fn serves(self, role: Role) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Rung::Hexagon => role != Role::Embedder,
|
Rung::Hexagon | Rung::CoreMl => role != Role::Embedder,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +360,12 @@ struct Cache {
|
|||||||
/// the fingerprint changes: a wedged driver must not cost every launch
|
/// the fingerprint changes: a wedged driver must not cost every launch
|
||||||
/// thirty seconds.
|
/// thirty seconds.
|
||||||
failed: Vec<(Rung, String)>,
|
failed: Vec<(Rung, String)>,
|
||||||
|
/// Engine keys whose compile the process died inside, launch after
|
||||||
|
/// launch (`probe::attempt`). Left on the fallback until the
|
||||||
|
/// fingerprint changes. Defaulted, so a cache from before this field
|
||||||
|
/// still reads.
|
||||||
|
#[serde(default)]
|
||||||
|
refused: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
@@ -631,6 +649,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coreml_takes_a_compiled_detector_and_never_the_embedder() {
|
||||||
|
let hash = engines::hash(b"detector");
|
||||||
|
let mut s = State {
|
||||||
|
config: Config::default(),
|
||||||
|
cache: Cache {
|
||||||
|
rung: Some(Rung::CoreMl),
|
||||||
|
..Cache::default()
|
||||||
|
},
|
||||||
|
probing: false,
|
||||||
|
wanted: 0,
|
||||||
|
};
|
||||||
|
let on = |s: &State, role| effective_rung(s, Rung::CoreMl, role, Form::F32, hash);
|
||||||
|
// Before its program is compiled the detector waits on the CPU.
|
||||||
|
assert_eq!(on(&s, Role::Detector), Rung::Cpu);
|
||||||
|
s.cache.compiled.insert(engines::key_of(Rung::CoreMl, hash));
|
||||||
|
assert_eq!(on(&s, Role::Detector), Rung::CoreMl);
|
||||||
|
// The embedder does not move, compiled or not (§7).
|
||||||
|
assert_eq!(on(&s, Role::Embedder), Rung::Cpu);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_status_reports_only_the_rungs_above_the_selection() {
|
fn the_status_reports_only_the_rungs_above_the_selection() {
|
||||||
let _serial = serial();
|
let _serial = serial();
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
|
|||||||
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let all = [Rung::Hexagon];
|
let all = [Rung::Hexagon];
|
||||||
|
// Unmeasured (§2 ⁵): it is on the ladder because the probe's clock and
|
||||||
|
// `attempt` make a wrong guess cost one slow or failed probe, not a
|
||||||
|
// slow or crashing app.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let all = [Rung::CoreMl];
|
||||||
// A desktop has one vendor's GPU; the other vendor's providers are
|
// A desktop has one vendor's GPU; the other vendor's providers are
|
||||||
// "not enabled in this build" or a library that fails to load, and
|
// "not enabled in this build" or a library that fails to load, and
|
||||||
// either answer arrives in milliseconds.
|
// either answer arrives in milliseconds.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(any(target_os = "android", target_os = "macos")))]
|
||||||
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
||||||
@@ -81,7 +86,11 @@ pub fn run(runtime: Runtime) {
|
|||||||
log::info!("inference: floor {floor:.1} ms on the CPU provider");
|
log::info!("inference: floor {floor:.1} ms on the CPU provider");
|
||||||
|
|
||||||
for rung in ladder(cfg.ceiling) {
|
for rung in ladder(cfg.ceiling) {
|
||||||
match time_rung(rung, role, &canonical, &cfg) {
|
let timed = attempt(&cfg, &format!("probe {}", rung.label()), || {
|
||||||
|
time_rung(rung, role, &canonical, &cfg)
|
||||||
|
})
|
||||||
|
.and_then(|timed| timed);
|
||||||
|
match timed {
|
||||||
Ok((ms, key)) if ms < floor => {
|
Ok((ms, key)) if ms < floor => {
|
||||||
cache.rung = Some(rung);
|
cache.rung = Some(rung);
|
||||||
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
|
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
|
||||||
@@ -118,6 +127,52 @@ fn finish(cache: Cache) {
|
|||||||
s.probing = false;
|
s.probing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many launches in a row may die inside one attempt before it is
|
||||||
|
/// refused. Two, not one: quitting the app while TensorRT spends forty
|
||||||
|
/// seconds on an engine leaves the same trace as a provider that aborted.
|
||||||
|
const STRIKES: u32 = 2;
|
||||||
|
|
||||||
|
/// Run `f` — a session build on a provider — with `what` written down
|
||||||
|
/// first, so that if the provider takes the process with it the next launch
|
||||||
|
/// knows what to stop trying.
|
||||||
|
///
|
||||||
|
/// A provider can fail by aborting rather than by returning an error:
|
||||||
|
/// XNNPACK did on SCRFD (§2), and a C++ exception or a panic across the C
|
||||||
|
/// API is an abort. The probe runs in the app's own process, so a rung that
|
||||||
|
/// does this once would do it on every launch, before the first photograph
|
||||||
|
/// is on screen. The file (`attempt` in the cache directory) holds the
|
||||||
|
/// attempt and how many launches have started it without finishing;
|
||||||
|
/// finishing, by success or by error, removes it. After [`STRIKES`] the
|
||||||
|
/// attempt is refused, and the caller records the refusal in the cache,
|
||||||
|
/// where it lasts until the fingerprint changes like any other failure.
|
||||||
|
pub fn attempt<T>(cfg: &Config, what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||||
|
if cfg.cache_dir.as_os_str().is_empty() {
|
||||||
|
return Ok(f());
|
||||||
|
}
|
||||||
|
let path = cfg.cache_dir.join("attempt");
|
||||||
|
let died = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
let (w, n) = s.split_once('\t')?;
|
||||||
|
(w == what).then(|| n.trim().parse::<u32>().ok())?
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if died >= STRIKES {
|
||||||
|
log::error!("inference: the app died during `{what}` on the last {died} launches; not trying it again");
|
||||||
|
return Err(format!(
|
||||||
|
"the app died while trying this on {died} launches in a row"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if died > 0 {
|
||||||
|
log::warn!("inference: the last launch died during `{what}`; trying it once more");
|
||||||
|
}
|
||||||
|
let _ = std::fs::create_dir_all(&cfg.cache_dir);
|
||||||
|
let _ = std::fs::write(&path, format!("{what}\t{}", died + 1));
|
||||||
|
let out = f();
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// The smallest detector, or the smallest model of any role if there is
|
/// The smallest detector, or the smallest model of any role if there is
|
||||||
/// none. A ~2 MB detector is the cheapest real test of a provider, and the
|
/// none. A ~2 MB detector is the cheapest real test of a provider, and the
|
||||||
/// detector is the role the int8 forms exist for — the eye classifiers are
|
/// detector is the role the int8 forms exist for — the eye classifiers are
|
||||||
@@ -310,7 +365,50 @@ fn system_property(name: &str) -> String {
|
|||||||
String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned()
|
String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
#[cfg(target_os = "macos")]
|
||||||
|
fn device_identity() -> String {
|
||||||
|
// The chip, and the OS release: CoreML ships with the OS, so a macOS
|
||||||
|
// update is a new provider as surely as a new driver is on Linux.
|
||||||
|
format!(
|
||||||
|
"{} macOS {}",
|
||||||
|
sysctl("machdep.cpu.brand_string"),
|
||||||
|
sysctl("kern.osproductversion")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn sysctl(name: &str) -> String {
|
||||||
|
extern "C" {
|
||||||
|
fn sysctlbyname(
|
||||||
|
name: *const std::ffi::c_char,
|
||||||
|
oldp: *mut std::ffi::c_void,
|
||||||
|
oldlenp: *mut usize,
|
||||||
|
newp: *mut std::ffi::c_void,
|
||||||
|
newlen: usize,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
let name = std::ffi::CString::new(name).unwrap();
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
let mut len = buf.len();
|
||||||
|
// SAFETY: libSystem's documented call; `len` is the buffer's size in and
|
||||||
|
// the string's length, with its terminator, out.
|
||||||
|
let rc = unsafe {
|
||||||
|
sysctlbyname(
|
||||||
|
name.as_ptr(),
|
||||||
|
buf.as_mut_ptr().cast(),
|
||||||
|
&mut len,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let s = &buf[..len.min(buf.len())];
|
||||||
|
String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
|
||||||
fn device_identity() -> String {
|
fn device_identity() -> String {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
@@ -338,3 +436,49 @@ pub fn write_cache(cfg: &Config, cache: &Cache) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn a_cache_dir(name: &str) -> Config {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-attempt-{}-{name}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
Config {
|
||||||
|
cache_dir: dir,
|
||||||
|
..Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a launch that died inside `what` leaves behind.
|
||||||
|
fn died_inside(cfg: &Config, what: &str, launches: u32) {
|
||||||
|
std::fs::create_dir_all(&cfg.cache_dir).unwrap();
|
||||||
|
std::fs::write(cfg.cache_dir.join("attempt"), format!("{what}\t{launches}")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_attempt_leaves_no_trace() {
|
||||||
|
let cfg = a_cache_dir("finished");
|
||||||
|
assert_eq!(attempt(&cfg, "probe CoreML", || 7), Ok(7));
|
||||||
|
assert!(!cfg.cache_dir.join("attempt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_death_is_forgiven_and_two_are_not() {
|
||||||
|
let cfg = a_cache_dir("strikes");
|
||||||
|
died_inside(&cfg, "probe CoreML", 1);
|
||||||
|
assert_eq!(attempt(&cfg, "probe CoreML", || 7), Ok(7));
|
||||||
|
|
||||||
|
died_inside(&cfg, "probe CoreML", 2);
|
||||||
|
let mut ran = false;
|
||||||
|
assert!(attempt(&cfg, "probe CoreML", || ran = true).is_err());
|
||||||
|
assert!(!ran, "a refused attempt must not run");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_attempts_deaths_do_not_count() {
|
||||||
|
let cfg = a_cache_dir("other");
|
||||||
|
died_inside(&cfg, "probe TensorRT", 2);
|
||||||
|
assert_eq!(attempt(&cfg, "probe CUDA", || 7), Ok(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,24 +19,61 @@ pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<
|
|||||||
// `stack_tensors`) — a panic across the C API, which is an abort. The
|
// `stack_tensors`) — a panic across the C API, which is an abort. The
|
||||||
// app never asked tract for that and does not start now.
|
// app never asked tract for that and does not start now.
|
||||||
let mut b = Session::builder()?.with_intra_threads(threads(cfg))?;
|
let mut b = Session::builder()?.with_intra_threads(threads(cfg))?;
|
||||||
|
if crate::api::runtime().is_native() {
|
||||||
|
b = with_runtime_log(b)?;
|
||||||
|
}
|
||||||
// A Hexagon session loads the compiled context when there is one and
|
// A Hexagon session loads the compiled context when there is one and
|
||||||
// compiles it from the model when there is not; the engine thread is
|
// compiles it from the model when there is not; the engine thread is
|
||||||
// what makes the second case rare (§6).
|
// what makes the second case rare (§6).
|
||||||
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
|
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
|
||||||
let ready = context.as_ref().is_some_and(|p| p.is_file());
|
let ready = context.as_ref().is_some_and(|p| p.is_file());
|
||||||
b = providers(
|
// What the rung keeps for this model: the context the Hexagon is to
|
||||||
b,
|
// write, or the directory CoreML compiles into.
|
||||||
rung,
|
let per_model = match rung {
|
||||||
role,
|
Rung::CoreMl => Some(crate::engines::coreml_dir(cfg, bytes)),
|
||||||
cfg,
|
_ if ready => None,
|
||||||
if ready { None } else { context.as_deref() },
|
_ => context.clone(),
|
||||||
)?;
|
};
|
||||||
|
b = providers(b, rung, role, cfg, per_model.as_deref())?;
|
||||||
match (ready, context) {
|
match (ready, context) {
|
||||||
(true, Some(path)) => b.commit_from_file(path),
|
(true, Some(path)) => b.commit_from_file(path),
|
||||||
_ => b.commit_from_memory(bytes),
|
_ => b.commit_from_memory(bytes),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send the runtime's own messages for this session to `log`, under the
|
||||||
|
/// target `onnxruntime`, instead of to ONNX Runtime's stdio logger.
|
||||||
|
///
|
||||||
|
/// Its stderr is nowhere once the app is launched from a menu, and what a
|
||||||
|
/// provider says while it partitions a graph — how many nodes it took, which
|
||||||
|
/// operator it declined, the library it failed to load — is most of what a
|
||||||
|
/// failed rung tells you (docs/dev/inference.md §4). The level follows the
|
||||||
|
/// filter: warnings always, `debug` adds the runtime's info lines (the
|
||||||
|
/// partition counts), `trace` its verbose ones (every node placement).
|
||||||
|
fn with_runtime_log(
|
||||||
|
b: ort::session::builder::SessionBuilder,
|
||||||
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
|
use ort::logging::LogLevel;
|
||||||
|
let level = if log::log_enabled!(target: "onnxruntime", log::Level::Trace) {
|
||||||
|
LogLevel::Verbose
|
||||||
|
} else if log::log_enabled!(target: "onnxruntime", log::Level::Debug) {
|
||||||
|
LogLevel::Info
|
||||||
|
} else {
|
||||||
|
LogLevel::Warning
|
||||||
|
};
|
||||||
|
let forward = |level: LogLevel, _category: &str, _id: &str, location: &str, message: &str| {
|
||||||
|
let level = match level {
|
||||||
|
LogLevel::Verbose => log::Level::Trace,
|
||||||
|
LogLevel::Info => log::Level::Debug,
|
||||||
|
LogLevel::Warning => log::Level::Warn,
|
||||||
|
LogLevel::Error | LogLevel::Fatal => log::Level::Error,
|
||||||
|
};
|
||||||
|
log::log!(target: "onnxruntime", level, "{message} ({location})");
|
||||||
|
};
|
||||||
|
Ok(b.with_logger(std::sync::Arc::new(forward))?
|
||||||
|
.with_log_level(level)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The intra-op pool: what the config says, else the cores less two for
|
/// The intra-op pool: what the config says, else the cores less two for
|
||||||
/// the compositor and the decoder (§9). tract ignores it.
|
/// the compositor and the decoder (§9). tract ignores it.
|
||||||
fn threads(cfg: &Config) -> usize {
|
fn threads(cfg: &Config) -> usize {
|
||||||
@@ -54,11 +91,12 @@ fn providers(
|
|||||||
rung: Rung,
|
rung: Rung,
|
||||||
role: Role,
|
role: Role,
|
||||||
cfg: &Config,
|
cfg: &Config,
|
||||||
_generate_context: Option<&std::path::Path>,
|
per_model: Option<&std::path::Path>,
|
||||||
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
use ort::ep;
|
use ort::ep;
|
||||||
match rung {
|
match rung {
|
||||||
Rung::Cpu => Ok(b),
|
Rung::Cpu => Ok(b),
|
||||||
|
Rung::CoreMl => coreml(b, per_model),
|
||||||
Rung::Cuda => {
|
Rung::Cuda => {
|
||||||
Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?)
|
Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?)
|
||||||
}
|
}
|
||||||
@@ -103,6 +141,43 @@ fn providers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CoreML, compiling an ML Program — the format with the operators these
|
||||||
|
/// graphs use and the one that reaches the Neural Engine — into `cache`.
|
||||||
|
///
|
||||||
|
/// The option names are those ONNX Runtime 1.29 reads from the generic
|
||||||
|
/// key/value map (`coreml_options.cc`), which is what `ort`'s builder
|
||||||
|
/// fills. The cache is per model because of how CoreML keys it: a model
|
||||||
|
/// committed from memory, as every session here is, has no path, and the
|
||||||
|
/// key falls back to a hash of the graph's input and node names — not its
|
||||||
|
/// weights. Two exports of one architecture would share a program. The
|
||||||
|
/// directory `engines::coreml_dir` names is the hash of the bytes.
|
||||||
|
///
|
||||||
|
/// Every compute unit is allowed, so CoreML may place a graph on the
|
||||||
|
/// Neural Engine, the GPU or the CPU; the probe's clock judges the result.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn coreml(
|
||||||
|
b: ort::session::builder::SessionBuilder,
|
||||||
|
cache: Option<&std::path::Path>,
|
||||||
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
|
use ort::ep::{self, coreml};
|
||||||
|
let mut ep = ep::CoreML::default()
|
||||||
|
.with_model_format(coreml::ModelFormat::MLProgram)
|
||||||
|
.with_compute_units(coreml::ComputeUnits::All);
|
||||||
|
if let Some(dir) = cache {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
ep = ep.with_model_cache_dir(dir.to_string_lossy());
|
||||||
|
}
|
||||||
|
Ok(b.with_execution_providers([ep.build().error_on_failure()])?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "macos")))]
|
||||||
|
fn coreml(
|
||||||
|
_b: ort::session::builder::SessionBuilder,
|
||||||
|
_cache: Option<&std::path::Path>,
|
||||||
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
|
unreachable!("the CoreML rung is on the macOS ladder only")
|
||||||
|
}
|
||||||
|
|
||||||
/// Register MIGraphX through ONNX Runtime's generic key/value entry point.
|
/// Register MIGraphX through ONNX Runtime's generic key/value entry point.
|
||||||
///
|
///
|
||||||
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
|
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
|
||||||
@@ -175,8 +250,8 @@ fn providers(
|
|||||||
.build()
|
.build()
|
||||||
.error_on_failure()])?)
|
.error_on_failure()])?)
|
||||||
}
|
}
|
||||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
|
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX | Rung::CoreMl => {
|
||||||
unreachable!("no desktop GPU rung on Android")
|
unreachable!("no desktop rung on Android")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
//! - [`align`] — the whole thing, from features to cameras, honest about
|
//! - [`align`] — the whole thing, from features to cameras, honest about
|
||||||
//! what it could not place.
|
//! what it could not place.
|
||||||
//! - [`projection`] — perspective, cylindrical, spherical.
|
//! - [`projection`] — perspective, cylindrical, spherical.
|
||||||
//! - [`seam`] — which frame each output pixel is taken from.
|
|
||||||
//! - [`linalg`] — the small dense algebra all of it uses.
|
//! - [`linalg`] — the small dense algebra all of it uses.
|
||||||
//!
|
//!
|
||||||
//! # What it depends on
|
//! # What it depends on
|
||||||
@@ -44,7 +43,6 @@ pub mod matching;
|
|||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod migan;
|
pub mod migan;
|
||||||
pub mod projection;
|
pub mod projection;
|
||||||
pub mod seam;
|
|
||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod xfeat;
|
pub mod xfeat;
|
||||||
|
|
||||||
@@ -54,7 +52,6 @@ pub use features::{Features, Keypoint};
|
|||||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||||
pub use image::Gray;
|
pub use image::Gray;
|
||||||
pub use projection::Projection;
|
pub use projection::Projection;
|
||||||
pub use seam::{SeamMap, SeamOptions};
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum PanoError {
|
pub enum PanoError {
|
||||||
|
|||||||
@@ -1,691 +0,0 @@
|
|||||||
//! TRACES: FR-MRG-10
|
|
||||||
//! Where each frame gives way to the next.
|
|
||||||
//!
|
|
||||||
//! The first merges averaged every overlap: each frame weighted by its
|
|
||||||
//! distance from its own edge, so that across two hundred pixels one frame
|
|
||||||
//! faded into the other. That hides an exposure step and does not hide
|
|
||||||
//! anything that differs between the frames — parallax on a near slope, a
|
|
||||||
//! walker, a branch in the wind — which the average draws twice, half as
|
|
||||||
//! bright, a soft double edge at 1:1.
|
|
||||||
//!
|
|
||||||
//! A seam answers it the way every stitcher does: in an overlap, each output
|
|
||||||
//! pixel is taken from *one* frame, and the line where the choice changes is
|
|
||||||
//! put where the frames agree and the picture is smooth — through sky,
|
|
||||||
//! along a shadow, round the walker rather than through him — and away from
|
|
||||||
//! either frame's edge, where vignetting and the lens correction's fringe
|
|
||||||
//! live. The blend is then narrow and only across that line.
|
|
||||||
//!
|
|
||||||
//! # How
|
|
||||||
//!
|
|
||||||
//! At proxy resolution, on the output surface, which fits (panorama.md §5:
|
|
||||||
//! "it is a mask, not an image"):
|
|
||||||
//!
|
|
||||||
//! 1. Frames are laid down one at a time, each next to one already placed.
|
|
||||||
//! The composite so far is a label per texel and the value its owner saw.
|
|
||||||
//! 2. Where a new frame overlaps the composite, a cost per texel: the
|
|
||||||
//! difference between the two (after the gains), how much detail either
|
|
||||||
//! has there, and how near either frame's edge it is — smoothed over a
|
|
||||||
//! few texels, because "agree" means locally, not at one pixel.
|
|
||||||
//! 3. The cut is a path across the overlap, perpendicular to the line from
|
|
||||||
//! the composite's frames to the new one, found by dynamic programming
|
|
||||||
//! one row at a time: the per-column seam panorama.md §4 chose over a
|
|
||||||
//! graph cut because it is the GPU-friendly shape. Texels on the new
|
|
||||||
//! frame's side of the path become its own.
|
|
||||||
//!
|
|
||||||
//! What the merge reads is [`SeamMap::share`]: the fraction of a small
|
|
||||||
//! window about a point that is labelled with a frame, tent-weighted, which
|
|
||||||
//! is a narrow blend that follows the seam. `merge.wgsl` computes the same
|
|
||||||
//! thing on the GPU from the same labels.
|
|
||||||
|
|
||||||
use crate::bundle::Cameras;
|
|
||||||
use crate::image::Gray;
|
|
||||||
use crate::projection::{self, Projection};
|
|
||||||
|
|
||||||
/// No frame owns this texel.
|
|
||||||
pub const NONE: u8 = 255;
|
|
||||||
|
|
||||||
/// The most frames a map can label: one less than [`NONE`].
|
|
||||||
pub const MAX_FRAMES: usize = NONE as usize;
|
|
||||||
|
|
||||||
/// Which frame each texel of the output takes its pixels from.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct SeamMap {
|
|
||||||
pub width: usize,
|
|
||||||
pub height: usize,
|
|
||||||
/// The projection scale the map was laid out at: the proxies' focal
|
|
||||||
/// length. Output coordinates at any other scale are this times the
|
|
||||||
/// ratio of the scales.
|
|
||||||
pub scale: f64,
|
|
||||||
/// Centred output coordinates, at `scale`, of texel (0, 0)'s top-left
|
|
||||||
/// corner.
|
|
||||||
pub origin: (f64, f64),
|
|
||||||
/// Output units per texel, at `scale`.
|
|
||||||
pub px: f64,
|
|
||||||
/// Row-major, one per texel: the frame's index, or [`NONE`].
|
|
||||||
pub labels: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub struct SeamOptions {
|
|
||||||
/// The widest the map is laid out, in texels. Wider than the proxies'
|
|
||||||
/// own resolution buys nothing.
|
|
||||||
pub max_width: usize,
|
|
||||||
/// How much detail costs against disagreement: a seam through texture
|
|
||||||
/// shows even where the frames agree, because the blend across it
|
|
||||||
/// softens it.
|
|
||||||
pub detail: f32,
|
|
||||||
/// How much a frame's edge costs, and how far in from it the cost
|
|
||||||
/// reaches, in proxy pixels. Frame edges are where vignetting is
|
|
||||||
/// darkest and the lens correction ran out of sensor.
|
|
||||||
pub edge: f32,
|
|
||||||
pub edge_margin: f32,
|
|
||||||
/// The radius, in texels, a texel's cost looks about it for the worst
|
|
||||||
/// of its neighbours: at least the radius the merge blends across.
|
|
||||||
pub smoothing: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SeamOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
SeamOptions {
|
|
||||||
max_width: 2048,
|
|
||||||
detail: 0.5,
|
|
||||||
edge: 0.5,
|
|
||||||
edge_margin: 24.0,
|
|
||||||
smoothing: 4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The most texels a blend reaches either side of a seam. The merge's
|
|
||||||
/// shader loads the square of twice this per pixel per frame near a seam.
|
|
||||||
pub const MAX_BLEND_RADIUS: f64 = 4.0;
|
|
||||||
|
|
||||||
/// Cost of a texel outside the overlap: high enough that the path keeps to
|
|
||||||
/// the overlap wherever there is one, finite so that a row with a gap in it
|
|
||||||
/// still has an answer.
|
|
||||||
const OUTSIDE: f32 = 1.0e3;
|
|
||||||
|
|
||||||
impl SeamMap {
|
|
||||||
/// The map's origin and texel size in the coordinates of an output
|
|
||||||
/// laid out at `scale` (the full-resolution focal length, or a fraction
|
|
||||||
/// of it).
|
|
||||||
pub fn at_scale(&self, scale: f64) -> ((f64, f64), f64) {
|
|
||||||
let r = scale / self.scale;
|
|
||||||
((self.origin.0 * r, self.origin.1 * r), self.px * r)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The radius, in texels, of a blend `blend_px` output pixels wide in an
|
|
||||||
/// output laid out at `scale`: what [`Self::share`] and the shader are
|
|
||||||
/// given, so that the preview and the merge blend alike.
|
|
||||||
pub fn blend_radius(&self, scale: f64, blend_px: f64) -> f64 {
|
|
||||||
let (_, px) = self.at_scale(scale);
|
|
||||||
(blend_px / 2.0 / px).clamp(1.0, MAX_BLEND_RADIUS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The share frame `k` has of output point `(u, v)` given at `scale`:
|
|
||||||
/// the tent-weighted fraction of the texels within `radius` (in texels)
|
|
||||||
/// that it owns. `None` where no texel in reach is owned at all — the
|
|
||||||
/// map has nothing to say there, and the caller falls back to its
|
|
||||||
/// feather.
|
|
||||||
///
|
|
||||||
/// This is the function `merge.wgsl`'s `seam_share` repeats; the two
|
|
||||||
/// must agree.
|
|
||||||
pub fn share(&self, k: usize, u: f64, v: f64, scale: f64, radius: f64) -> Option<f32> {
|
|
||||||
let ((ou, ov), px) = self.at_scale(scale);
|
|
||||||
let x = (u - ou) / px - 0.5;
|
|
||||||
let y = (v - ov) / px - 0.5;
|
|
||||||
let r = radius.max(1.0);
|
|
||||||
let (x0, x1) = ((x - r).ceil() as i64, (x + r).floor() as i64);
|
|
||||||
let (y0, y1) = ((y - r).ceil() as i64, (y + r).floor() as i64);
|
|
||||||
let (mut mine, mut all) = (0.0f64, 0.0f64);
|
|
||||||
for j in y0.max(0)..=y1.min(self.height as i64 - 1) {
|
|
||||||
let wy = 1.0 - (y - j as f64).abs() / r;
|
|
||||||
if wy <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for i in x0.max(0)..=x1.min(self.width as i64 - 1) {
|
|
||||||
let wx = 1.0 - (x - i as f64).abs() / r;
|
|
||||||
if wx <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let l = self.labels[j as usize * self.width + i as usize];
|
|
||||||
if l == NONE {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
all += wx * wy;
|
|
||||||
if usize::from(l) == k {
|
|
||||||
mine += wx * wy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(all > 0.0).then(|| (mine / all) as f32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One frame warped onto the map: its gain-corrected value and its distance
|
|
||||||
/// from its own edge (in proxy pixels) per texel, NaN where it does not
|
|
||||||
/// reach.
|
|
||||||
struct Warped {
|
|
||||||
value: Vec<f32>,
|
|
||||||
edge: Vec<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lay seams across the overlaps of `proxies`, aligned by `cameras` (at the
|
|
||||||
/// proxies' scale), with `gains` the linear multipliers the merge will
|
|
||||||
/// apply. `None` if the frames project nowhere or there are more than
|
|
||||||
/// [`MAX_FRAMES`].
|
|
||||||
pub fn find(
|
|
||||||
proxies: &[&Gray],
|
|
||||||
cameras: &Cameras,
|
|
||||||
gains: &[f32],
|
|
||||||
projection: Projection,
|
|
||||||
opts: &SeamOptions,
|
|
||||||
) -> Option<SeamMap> {
|
|
||||||
let n = proxies.len();
|
|
||||||
if n == 0 || n > MAX_FRAMES || cameras.rotations.len() != n || gains.len() != n {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
|
||||||
let scale = cameras.focal;
|
|
||||||
let bounds = projection::bounds(projection, scale, cameras, (fw, fh))?;
|
|
||||||
let width = opts.max_width.min(bounds.width().ceil() as usize).max(1);
|
|
||||||
let px = bounds.width() / width as f64;
|
|
||||||
let height = ((bounds.height() / px).ceil() as usize).max(1);
|
|
||||||
let mut map = SeamMap {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
scale,
|
|
||||||
origin: (bounds.min_u, bounds.min_v),
|
|
||||||
px,
|
|
||||||
labels: vec![NONE; width * height],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Where each frame's centre lands, in texels: what orders the frames
|
|
||||||
// and orients each cut.
|
|
||||||
let centres: Vec<(f64, f64)> = (0..n)
|
|
||||||
.map(|k| {
|
|
||||||
let d = cameras.bearing(k, (0.0, 0.0));
|
|
||||||
projection
|
|
||||||
.from_direction(scale, d)
|
|
||||||
.map(|(u, v)| ((u - bounds.min_u) / px, (v - bounds.min_v) / px))
|
|
||||||
.unwrap_or((width as f64 / 2.0, height as f64 / 2.0))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// The composite so far: what its owner saw, and how far from the
|
|
||||||
// owner's edge.
|
|
||||||
let mut value = vec![f32::NAN; width * height];
|
|
||||||
let mut edge = vec![f32::NAN; width * height];
|
|
||||||
|
|
||||||
for k in order(¢res, (width as f64 / 2.0, height as f64 / 2.0)) {
|
|
||||||
let w = warp(&map, proxies[k], cameras, k, gains[k], projection);
|
|
||||||
let overlap: Vec<usize> = (0..width * height)
|
|
||||||
.filter(|&i| map.labels[i] != NONE && !w.value[i].is_nan())
|
|
||||||
.collect();
|
|
||||||
// Texels nobody owns yet are the new frame's without a cut.
|
|
||||||
let mut take: Vec<bool> = map
|
|
||||||
.labels
|
|
||||||
.iter()
|
|
||||||
.zip(&w.value)
|
|
||||||
.map(|(&l, v)| l == NONE && !v.is_nan())
|
|
||||||
.collect();
|
|
||||||
if !overlap.is_empty() {
|
|
||||||
cut(
|
|
||||||
&map, &value, &edge, &w, &overlap, ¢res, k, opts, &mut take,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for i in 0..width * height {
|
|
||||||
if take[i] {
|
|
||||||
map.labels[i] = k as u8;
|
|
||||||
value[i] = w.value[i];
|
|
||||||
edge[i] = w.edge[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The order frames are laid down in: the one nearest the middle first,
|
|
||||||
/// then always the unplaced frame nearest any placed one, so that each new
|
|
||||||
/// frame meets the composite along an overlap rather than across a gap.
|
|
||||||
fn order(centres: &[(f64, f64)], middle: (f64, f64)) -> Vec<usize> {
|
|
||||||
let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2);
|
|
||||||
let n = centres.len();
|
|
||||||
let mut placed = vec![false; n];
|
|
||||||
let mut out = Vec::with_capacity(n);
|
|
||||||
let first = (0..n)
|
|
||||||
.min_by(|&a, &b| d2(centres[a], middle).total_cmp(&d2(centres[b], middle)))
|
|
||||||
.expect("at least one frame");
|
|
||||||
placed[first] = true;
|
|
||||||
out.push(first);
|
|
||||||
while out.len() < n {
|
|
||||||
let next = (0..n)
|
|
||||||
.filter(|&k| !placed[k])
|
|
||||||
.min_by(|&a, &b| {
|
|
||||||
let near = |k: usize| {
|
|
||||||
out.iter()
|
|
||||||
.map(|&p| d2(centres[k], centres[p]))
|
|
||||||
.fold(f64::MAX, f64::min)
|
|
||||||
};
|
|
||||||
near(a).total_cmp(&near(b))
|
|
||||||
})
|
|
||||||
.expect("an unplaced frame");
|
|
||||||
placed[next] = true;
|
|
||||||
out.push(next);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frame `k` sampled at every texel's centre, bilinearly. The proxy is
|
|
||||||
/// gamma-encoded grey, so the gain (linear) becomes `gain^(1/2.2)` on it.
|
|
||||||
fn warp(
|
|
||||||
map: &SeamMap,
|
|
||||||
g: &Gray,
|
|
||||||
cameras: &Cameras,
|
|
||||||
k: usize,
|
|
||||||
gain: f32,
|
|
||||||
projection: Projection,
|
|
||||||
) -> Warped {
|
|
||||||
let (fw, fh) = (g.width as f64, g.height as f64);
|
|
||||||
let gain = gain.max(1e-6).powf(1.0 / 2.2);
|
|
||||||
let mut value = vec![f32::NAN; map.width * map.height];
|
|
||||||
let mut edge = vec![f32::NAN; map.width * map.height];
|
|
||||||
for ty in 0..map.height {
|
|
||||||
let v = map.origin.1 + (ty as f64 + 0.5) * map.px;
|
|
||||||
for tx in 0..map.width {
|
|
||||||
let u = map.origin.0 + (tx as f64 + 0.5) * map.px;
|
|
||||||
let d = projection.to_direction(map.scale, u, v);
|
|
||||||
let Some((x, y)) = cameras.project(k, d) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let (x, y) = (x + fw / 2.0 - 0.5, y + fh / 2.0 - 0.5);
|
|
||||||
let e = x.min(fw - 1.0 - x).min(y).min(fh - 1.0 - y);
|
|
||||||
if e < 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
|
|
||||||
let (x1, y1) = ((x0 + 1).min(g.width - 1), (y0 + 1).min(g.height - 1));
|
|
||||||
let (ax, ay) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
|
||||||
let at = |xx: usize, yy: usize| g.data[yy * g.width + xx];
|
|
||||||
let top = at(x0, y0) * (1.0 - ax) + at(x1, y0) * ax;
|
|
||||||
let bot = at(x0, y1) * (1.0 - ax) + at(x1, y1) * ax;
|
|
||||||
let i = ty * map.width + tx;
|
|
||||||
value[i] = (top * (1.0 - ay) + bot * ay) * gain;
|
|
||||||
edge[i] = e as f32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Warped { value, edge }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Central-difference gradient magnitude of `plane` at texel `i`, from the
|
|
||||||
/// neighbours that exist.
|
|
||||||
fn detail(plane: &[f32], width: usize, height: usize, i: usize) -> f32 {
|
|
||||||
let (x, y) = (i % width, i / width);
|
|
||||||
let c = plane[i];
|
|
||||||
let mut g = 0.0f32;
|
|
||||||
let mut diff = |j: usize| {
|
|
||||||
let n = plane[j];
|
|
||||||
if !n.is_nan() {
|
|
||||||
g = g.max((n - c).abs());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if x > 0 {
|
|
||||||
diff(i - 1);
|
|
||||||
}
|
|
||||||
if x + 1 < width {
|
|
||||||
diff(i + 1);
|
|
||||||
}
|
|
||||||
if y > 0 {
|
|
||||||
diff(i - width);
|
|
||||||
}
|
|
||||||
if y + 1 < height {
|
|
||||||
diff(i + width);
|
|
||||||
}
|
|
||||||
g
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cut the overlap between the composite and frame `k`, marking in `take`
|
|
||||||
/// the overlap texels that go to `k`.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn cut(
|
|
||||||
map: &SeamMap,
|
|
||||||
value: &[f32],
|
|
||||||
edge: &[f32],
|
|
||||||
new: &Warped,
|
|
||||||
overlap: &[usize],
|
|
||||||
centres: &[(f64, f64)],
|
|
||||||
k: usize,
|
|
||||||
opts: &SeamOptions,
|
|
||||||
take: &mut [bool],
|
|
||||||
) {
|
|
||||||
let (w, h) = (map.width, map.height);
|
|
||||||
|
|
||||||
// The raw cost per overlap texel.
|
|
||||||
let mut raw = vec![f32::NAN; w * h];
|
|
||||||
let margin = opts.edge_margin.max(1.0);
|
|
||||||
for &i in overlap {
|
|
||||||
let differ = (value[i] - new.value[i]).abs();
|
|
||||||
let detail = detail(value, w, h, i).max(detail(&new.value, w, h, i));
|
|
||||||
let near = (1.0 - edge[i].min(new.edge[i]) / margin).max(0.0);
|
|
||||||
raw[i] = differ + opts.detail * detail + opts.edge * near * near + 1e-3;
|
|
||||||
}
|
|
||||||
// The worst over a small window: a texel is only cheap if its whole
|
|
||||||
// neighbourhood agrees, so the path keeps at least the blend's radius
|
|
||||||
// clear of a difference rather than threading the one lucky texel
|
|
||||||
// beside it — the blend straddles the path by that much and would
|
|
||||||
// otherwise reach the difference anyway.
|
|
||||||
let r = opts.smoothing as isize;
|
|
||||||
let mut cost = vec![OUTSIDE; w * h];
|
|
||||||
for &i in overlap {
|
|
||||||
let (x, y) = ((i % w) as isize, (i / w) as isize);
|
|
||||||
let mut worst = 0.0f32;
|
|
||||||
for dy in -r..=r {
|
|
||||||
for dx in -r..=r {
|
|
||||||
let (xx, yy) = (x + dx, y + dy);
|
|
||||||
if xx < 0 || yy < 0 || xx >= w as isize || yy >= h as isize {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let c = raw[yy as usize * w + xx as usize];
|
|
||||||
if !c.is_nan() {
|
|
||||||
worst = worst.max(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cost[i] = worst;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The axis the cut crosses: from the composite's frames, weighted by how
|
|
||||||
// much of the overlap each owns, to the new frame.
|
|
||||||
let mut from = (0.0f64, 0.0f64);
|
|
||||||
for &i in overlap {
|
|
||||||
let c = centres[usize::from(map.labels[i])];
|
|
||||||
from = (from.0 + c.0, from.1 + c.1);
|
|
||||||
}
|
|
||||||
let m = overlap.len() as f64;
|
|
||||||
from = (from.0 / m, from.1 / m);
|
|
||||||
let to = centres[k];
|
|
||||||
let (mut ax, mut ay) = (to.0 - from.0, to.1 - from.1);
|
|
||||||
let len = (ax * ax + ay * ay).sqrt();
|
|
||||||
if len < 1e-6 {
|
|
||||||
(ax, ay) = (1.0, 0.0);
|
|
||||||
} else {
|
|
||||||
(ax, ay) = (ax / len, ay / len);
|
|
||||||
}
|
|
||||||
// Along the cut: perpendicular to the axis.
|
|
||||||
let (bx, by) = (-ay, ax);
|
|
||||||
|
|
||||||
// The overlap's extent in (s along the cut, t across it).
|
|
||||||
let st = |i: usize| {
|
|
||||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
|
||||||
(x * bx + y * by, x * ax + y * ay)
|
|
||||||
};
|
|
||||||
let (mut s0, mut s1, mut t0, mut t1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
|
|
||||||
for &i in overlap {
|
|
||||||
let (s, t) = st(i);
|
|
||||||
s0 = s0.min(s);
|
|
||||||
s1 = s1.max(s);
|
|
||||||
t0 = t0.min(t);
|
|
||||||
t1 = t1.max(t);
|
|
||||||
}
|
|
||||||
let rows = (s1 - s0).round() as usize + 1;
|
|
||||||
let cols = (t1 - t0).round() as usize + 1;
|
|
||||||
|
|
||||||
// The grid in (s, t), each cell sampled from the texel it falls in, so
|
|
||||||
// that a rotated overlap has no holes.
|
|
||||||
let mut grid = vec![OUTSIDE; rows * cols];
|
|
||||||
let mut any = vec![false; rows];
|
|
||||||
for si in 0..rows {
|
|
||||||
for ti in 0..cols {
|
|
||||||
let (s, t) = (s0 + si as f64, t0 + ti as f64);
|
|
||||||
let x = s * bx + t * ax;
|
|
||||||
let y = s * by + t * ay;
|
|
||||||
if x < 0.0 || y < 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (x, y) = (x as usize, y as usize);
|
|
||||||
if x >= w || y >= h {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let c = cost[y * w + x];
|
|
||||||
if c < OUTSIDE {
|
|
||||||
grid[si * cols + ti] = c;
|
|
||||||
any[si] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic programming down the rows: the path moves at most one column
|
|
||||||
// per row, and starts afresh after a row with no overlap in it.
|
|
||||||
let mut acc = grid.clone();
|
|
||||||
let mut from_col = vec![0u32; rows * cols];
|
|
||||||
for si in 1..rows {
|
|
||||||
if !any[si] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let prev = &acc[(si - 1) * cols..si * cols].to_vec();
|
|
||||||
if !any[si - 1] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for ti in 0..cols {
|
|
||||||
let mut best = (prev[ti], ti);
|
|
||||||
if ti > 0 && prev[ti - 1] < best.0 {
|
|
||||||
best = (prev[ti - 1], ti - 1);
|
|
||||||
}
|
|
||||||
if ti + 1 < cols && prev[ti + 1] < best.0 {
|
|
||||||
best = (prev[ti + 1], ti + 1);
|
|
||||||
}
|
|
||||||
acc[si * cols + ti] += best.0;
|
|
||||||
from_col[si * cols + ti] = best.1 as u32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Back up from the end of each run of rows with overlap.
|
|
||||||
let mut seam = vec![usize::MAX; rows];
|
|
||||||
let mut si = rows;
|
|
||||||
while si > 0 {
|
|
||||||
si -= 1;
|
|
||||||
if !any[si] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let row = &acc[si * cols..(si + 1) * cols];
|
|
||||||
let mut t = (0..cols)
|
|
||||||
.min_by(|&a, &b| row[a].total_cmp(&row[b]))
|
|
||||||
.unwrap_or(0);
|
|
||||||
loop {
|
|
||||||
seam[si] = t;
|
|
||||||
if si == 0 || !any[si - 1] {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
t = from_col[si * cols + t] as usize;
|
|
||||||
si -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new frame takes the side of the path its centre is on.
|
|
||||||
for &i in overlap {
|
|
||||||
let (s, t) = st(i);
|
|
||||||
let si = ((s - s0).round() as usize).min(rows - 1);
|
|
||||||
let ti = (t - t0).round();
|
|
||||||
if seam[si] != usize::MAX && ti >= seam[si] as f64 {
|
|
||||||
take[i] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::linalg::{Mat3, Vec3};
|
|
||||||
|
|
||||||
/// A scene as a function of direction, and frames of it rendered by the
|
|
||||||
/// same cameras the seam reads.
|
|
||||||
fn render(
|
|
||||||
cameras: &Cameras,
|
|
||||||
k: usize,
|
|
||||||
size: (usize, usize),
|
|
||||||
scene: impl Fn(Vec3) -> f32,
|
|
||||||
) -> Gray {
|
|
||||||
let (w, h) = size;
|
|
||||||
let mut data = vec![0.0; w * h];
|
|
||||||
for y in 0..h {
|
|
||||||
for x in 0..w {
|
|
||||||
let p = (
|
|
||||||
x as f64 + 0.5 - w as f64 / 2.0,
|
|
||||||
y as f64 + 0.5 - h as f64 / 2.0,
|
|
||||||
);
|
|
||||||
data[y * w + x] = scene(cameras.bearing(k, p));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Gray {
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yaw(a: f64) -> Mat3 {
|
|
||||||
let (s, c) = a.sin_cos();
|
|
||||||
Mat3([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Smooth, with a little texture: what a sky over a slope looks like to
|
|
||||||
/// the cost.
|
|
||||||
fn landscape(d: Vec3) -> f32 {
|
|
||||||
let (x, y) = (d.x() / d.z(), d.y() / d.z());
|
|
||||||
let texture = if y > 0.1 { 0.1 * (y * 40.0).sin() } else { 0.0 };
|
|
||||||
(0.5 + 0.2 * (x * 3.0).sin() + texture).clamp(0.0, 1.0) as f32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pair() -> Cameras {
|
|
||||||
Cameras {
|
|
||||||
rotations: vec![Mat3::IDENTITY, yaw(0.35)],
|
|
||||||
focal: 300.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_frame_owns_everything_it_reaches() {
|
|
||||||
let cameras = Cameras {
|
|
||||||
rotations: vec![Mat3::IDENTITY],
|
|
||||||
focal: 300.0,
|
|
||||||
};
|
|
||||||
let g = render(&cameras, 0, (320, 240), landscape);
|
|
||||||
let map = find(
|
|
||||||
&[&g],
|
|
||||||
&cameras,
|
|
||||||
&[1.0],
|
|
||||||
Projection::Perspective,
|
|
||||||
&Default::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let owned = map.labels.iter().filter(|&&l| l == 0).count();
|
|
||||||
assert!(owned as f64 > 0.95 * (map.width * map.height) as f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn each_frame_keeps_its_own_side() {
|
|
||||||
let cameras = pair();
|
|
||||||
let frames: Vec<Gray> = (0..2)
|
|
||||||
.map(|k| render(&cameras, k, (320, 240), landscape))
|
|
||||||
.collect();
|
|
||||||
let refs: Vec<&Gray> = frames.iter().collect();
|
|
||||||
let map = find(
|
|
||||||
&refs,
|
|
||||||
&cameras,
|
|
||||||
&[1.0, 1.0],
|
|
||||||
Projection::Cylindrical,
|
|
||||||
&Default::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mid = map.height / 2 * map.width;
|
|
||||||
assert_eq!(map.labels[mid + 2], 0, "the left edge is frame 0's alone");
|
|
||||||
assert_eq!(
|
|
||||||
map.labels[mid + map.width - 3],
|
|
||||||
1,
|
|
||||||
"the right edge is frame 1's"
|
|
||||||
);
|
|
||||||
// One change of owner along every row that both frames cross.
|
|
||||||
for y in 0..map.height {
|
|
||||||
let row = &map.labels[y * map.width..(y + 1) * map.width];
|
|
||||||
let owned: Vec<u8> = row.iter().copied().filter(|&l| l != NONE).collect();
|
|
||||||
let changes = owned.windows(2).filter(|p| p[0] != p[1]).count();
|
|
||||||
assert!(changes <= 1, "row {y} changes owner {changes} times");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_seam_goes_round_what_only_one_frame_saw() {
|
|
||||||
// Frame 1 saw something frame 0 did not — a figure that walked into
|
|
||||||
// the overlap — in the middle of where the two meet.
|
|
||||||
let cameras = pair();
|
|
||||||
let figure = Vec3::new(0.175f64.sin(), 0.0, 0.175f64.cos());
|
|
||||||
let walker = |d: Vec3| {
|
|
||||||
let near = (d.x() - figure.x()).abs() < 0.04 && (d.y() - figure.y()).abs() < 0.15;
|
|
||||||
if near {
|
|
||||||
0.95
|
|
||||||
} else {
|
|
||||||
landscape(d)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let frames = [
|
|
||||||
render(&cameras, 0, (320, 240), landscape),
|
|
||||||
render(&cameras, 1, (320, 240), walker),
|
|
||||||
];
|
|
||||||
let refs: Vec<&Gray> = frames.iter().collect();
|
|
||||||
let map = find(
|
|
||||||
&refs,
|
|
||||||
&cameras,
|
|
||||||
&[1.0, 1.0],
|
|
||||||
Projection::Cylindrical,
|
|
||||||
&Default::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// Every texel of the figure is taken from the same frame, with a
|
|
||||||
// blend radius of room to spare, so it is either all there or not at
|
|
||||||
// all — never half.
|
|
||||||
let (u, v) = Projection::Cylindrical
|
|
||||||
.from_direction(map.scale, figure)
|
|
||||||
.unwrap();
|
|
||||||
let mut owners = std::collections::HashSet::new();
|
|
||||||
// The figure's extent on the surface, plus the blend's radius.
|
|
||||||
let radius = 3.0;
|
|
||||||
let reach = |half: f64| half * map.scale + radius * map.px;
|
|
||||||
let (ru, rv) = (reach(0.04), reach(0.15));
|
|
||||||
let mut dv = -rv;
|
|
||||||
while dv <= rv {
|
|
||||||
let mut du = -ru;
|
|
||||||
while du <= ru {
|
|
||||||
let s = map.share(1, u + du, v + dv, map.scale, radius);
|
|
||||||
owners.insert((s.unwrap() * 100.0).round() as i32);
|
|
||||||
du += map.px;
|
|
||||||
}
|
|
||||||
dv += map.px;
|
|
||||||
}
|
|
||||||
assert_eq!(owners.len(), 1, "the figure is split: shares {owners:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn share_is_a_blend_across_the_seam_and_whole_away_from_it() {
|
|
||||||
let map = SeamMap {
|
|
||||||
width: 8,
|
|
||||||
height: 1,
|
|
||||||
scale: 1.0,
|
|
||||||
origin: (0.0, 0.0),
|
|
||||||
px: 1.0,
|
|
||||||
labels: vec![0, 0, 0, 0, 1, 1, 1, 1],
|
|
||||||
};
|
|
||||||
assert_eq!(map.share(0, 1.5, 0.5, 1.0, 2.0), Some(1.0));
|
|
||||||
assert_eq!(map.share(1, 6.5, 0.5, 1.0, 2.0), Some(1.0));
|
|
||||||
let at_seam = map.share(0, 4.0, 0.5, 1.0, 2.0).unwrap();
|
|
||||||
assert!((at_seam - 0.5).abs() < 1e-6, "{at_seam}");
|
|
||||||
// And at twice the scale, the same point is twice as far out.
|
|
||||||
assert_eq!(
|
|
||||||
map.share(0, 8.0, 1.0, 2.0, 2.0),
|
|
||||||
map.share(0, 4.0, 0.5, 1.0, 2.0)
|
|
||||||
);
|
|
||||||
let empty = SeamMap {
|
|
||||||
labels: vec![NONE; 8],
|
|
||||||
..map
|
|
||||||
};
|
|
||||||
assert_eq!(empty.share(0, 4.0, 0.5, 1.0, 2.0), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# DarkRoom — macOS link check
|
||||||
|
#
|
||||||
|
# Compiles and links for macOS from Linux, with zig as the linker
|
||||||
|
# (cargo-zigbuild). Zig carries macOS's libSystem stubs and C headers, so the
|
||||||
|
# crates that need only libSystem — the inference engine, dr-plat — build,
|
||||||
|
# link and produce Mach-O test binaries here. Nothing runs: there is no macOS
|
||||||
|
# to run them on (docs/dev/macos.md §2). The desktop app needs Apple's
|
||||||
|
# framework headers (AppKit, Metal, Security), which only the Xcode SDK
|
||||||
|
# carries, so it does not link here.
|
||||||
|
#
|
||||||
|
# Build: docker build -t darkroom-macos:latest docker/macos
|
||||||
|
# Use: ./docker/macos/build.sh cargo zigbuild --target aarch64-apple-darwin -p dr-inference-engine --all-targets
|
||||||
|
|
||||||
|
FROM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
|
# Pinned, like the Windows and Android images. Rust matches rust-toolchain.toml.
|
||||||
|
ARG RUST_VERSION=1.92.0
|
||||||
|
ARG ZIG_VERSION=0.15.2
|
||||||
|
ARG ZIG_SHA256=02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239
|
||||||
|
ARG CARGO_ZIGBUILD_VERSION=0.23.4
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
CARGO_HOME=/opt/cargo \
|
||||||
|
RUSTUP_HOME=/opt/rustup \
|
||||||
|
PATH=/opt/zig:/opt/cargo/bin:$PATH
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git xz-utils \
|
||||||
|
# A host C compiler: build scripts and proc-macros are Linux binaries.
|
||||||
|
gcc libc6-dev \
|
||||||
|
# `file` says Mach-O; the smoke check in build.sh reads it.
|
||||||
|
file \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
|
||||||
|
&& echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - \
|
||||||
|
&& mkdir /opt/zig && tar xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 \
|
||||||
|
&& rm /tmp/zig.tar.xz && zig version
|
||||||
|
|
||||||
|
# The components rust-toolchain.toml lists, baked in so rustup does not fetch
|
||||||
|
# them inside every run.
|
||||||
|
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal \
|
||||||
|
--default-toolchain "${RUST_VERSION}" \
|
||||||
|
--component rustfmt,clippy,rust-analyzer \
|
||||||
|
--target aarch64-apple-darwin,x86_64-apple-darwin \
|
||||||
|
&& cargo install --locked "cargo-zigbuild@${CARGO_ZIGBUILD_VERSION}" \
|
||||||
|
&& rm -rf /opt/cargo/registry \
|
||||||
|
&& chmod -R a+rwX /opt/cargo /opt/rustup
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run a command inside the DarkRoom macOS link-check container.
|
||||||
|
#
|
||||||
|
# ./docker/macos/build.sh cargo zigbuild --target aarch64-apple-darwin -p dr-inference-engine --all-targets
|
||||||
|
# ./docker/macos/build.sh # interactive shell
|
||||||
|
#
|
||||||
|
# Builds the image on first use; `--rebuild` after editing the Dockerfile.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="darkroom-macos:latest"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||||
|
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
ENGINE=podman
|
||||||
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
|
ENGINE=docker
|
||||||
|
else
|
||||||
|
echo "error: neither podman nor docker found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--rebuild" ]]; then
|
||||||
|
shift
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||||
|
elif ! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||||
|
echo "==> building ${IMAGE} (first run; a few minutes)"
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Registry, target and zig's own cache persist across runs.
|
||||||
|
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-macos"
|
||||||
|
mkdir -p "${CACHE}/registry" "${CACHE}/target" "${CACHE}/home"
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
--rm
|
||||||
|
-v "${REPO}:/work:z"
|
||||||
|
-v "${CACHE}/registry:/opt/cargo/registry:z"
|
||||||
|
-v "${CACHE}/target:/work/target-macos:z"
|
||||||
|
-v "${CACHE}/home:/tmp/home:z"
|
||||||
|
-e HOME=/tmp/home
|
||||||
|
-e CARGO_TARGET_DIR=/work/target-macos
|
||||||
|
-w /work
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capped for the same reason as the Windows image: a cross build otherwise
|
||||||
|
# takes every thread on the host.
|
||||||
|
JOBS="${DARKROOM_BUILD_JOBS:-8}"
|
||||||
|
if [[ "${JOBS}" != "0" ]]; then
|
||||||
|
ARGS+=(--cpus "${JOBS}" -e "CARGO_BUILD_JOBS=${JOBS}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${ENGINE}" == "docker" ]]; then
|
||||||
|
ARGS+=(--user "$(id -u):$(id -g)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
ARGS+=(-it)
|
||||||
|
set -- /bin/bash
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
|
||||||
+15
-3
@@ -151,10 +151,14 @@ winning:
|
|||||||
| Linux / Windows, NVIDIA GPU | TensorRT, f32 model, fp16 engine | CUDA provider, f32 | ORT CPU, f32 | tract |
|
| Linux / Windows, NVIDIA GPU | TensorRT, f32 model, fp16 engine | CUDA provider, f32 | ORT CPU, f32 | tract |
|
||||||
| Linux, AMD GPU with ROCm | MIGraphX, f32 model, fp16 program | ORT CPU, f32 | — | tract |
|
| Linux, AMD GPU with ROCm | MIGraphX, f32 model, fp16 program | ORT CPU, f32 | — | tract |
|
||||||
| Linux / Windows, no GPU stack | ORT CPU, f32 | — | — | tract |
|
| Linux / Windows, no GPU stack | ORT CPU, f32 | — | — | tract |
|
||||||
| macOS ⁵ | ORT CPU, f32 | — | — | tract |
|
| macOS ⁵ | CoreML, f32 model, ML Program | ORT CPU, f32 | — | tract |
|
||||||
|
|
||||||
⁵ CoreML is the obvious rung and is unmeasured; it is listed so its absence is a gap and not an
|
⁵ **Unmeasured**, and the one exception to the rule below: nobody here has a Mac. The rung is on
|
||||||
oversight.
|
the ladder because the probe makes a wrong guess cheap — a CoreML that is slower than the CPU is
|
||||||
|
rejected by §4's clock, one that errors is recorded as failed, and one that takes the process
|
||||||
|
down is refused on the third launch (§4, `attempt`). The embedder stays on the CPU (§7). The first
|
||||||
|
macOS log that shows a probe line is this row's measurement; [macos.md](macos.md) says what to
|
||||||
|
ask for.
|
||||||
|
|
||||||
Deliberately **not** on any ladder, with the measurement that excluded each: NNAPI (no driver),
|
Deliberately **not** on any ladder, with the measurement that excluded each: NNAPI (no driver),
|
||||||
XNNPACK (slower than CPU, aborts on SCRFD), WebGPU (slower than CPU), the Adreno through QNN (works,
|
XNNPACK (slower than CPU, aborts on SCRFD), WebGPU (slower than CPU), the Adreno through QNN (works,
|
||||||
@@ -270,6 +274,14 @@ What the probe may not do:
|
|||||||
- **Retry a rung that failed within a session.** A failed probe is cached as a failure with the
|
- **Retry a rung that failed within a session.** A failed probe is cached as a failure with the
|
||||||
same inputs; the rung is tried again when an input changes. Otherwise a wedged driver means a
|
same inputs; the rung is tried again when an input changes. Otherwise a wedged driver means a
|
||||||
thirty-second stall on every launch.
|
thirty-second stall on every launch.
|
||||||
|
- **Crash the app twice for the same reason.** The probe runs in the app's process, and a provider
|
||||||
|
can fail by aborting rather than by returning an error (XNNPACK on SCRFD, §2). Every session build
|
||||||
|
on a rung above the CPU — the probe's, and each background compile of §6 — writes what it is
|
||||||
|
attempting to `attempt` in the cache directory first and removes it after. A launch that finds the
|
||||||
|
file knows the last one died inside that attempt; after two such launches in a row the attempt is
|
||||||
|
refused and recorded like any other failure (a rung in `failed`, an engine in `refused`), until
|
||||||
|
the fingerprint changes. Two, not one, because quitting during a forty-second TensorRT compile
|
||||||
|
leaves the same file.
|
||||||
- **Choose for the user without saying so.** Settings gains one row, *Inference backend*, showing
|
- **Choose for the user without saying so.** Settings gains one row, *Inference backend*, showing
|
||||||
what was chosen and why in one line ("Hexagon NPU · int8 · QNN 2.42"; "CPU · ONNX Runtime 1.30 ·
|
what was chosen and why in one line ("Hexagon NPU · int8 · QNN 2.42"; "CPU · ONNX Runtime 1.30 ·
|
||||||
TensorRT probe failed: cuDNN 8 required"), with an override to force any lower rung. The about
|
TensorRT probe failed: cuDNN 8 required"), with an override to force any lower rung. The about
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# macOS
|
||||||
|
|
||||||
|
macOS is out of scope for v1 ([requirements.md](requirements.md)), and nobody working on
|
||||||
|
DarkRoom has a Mac. This page records what exists anyway, and how a macOS build is set up so
|
||||||
|
that someone who does have one can send back enough to fix what they hit.
|
||||||
|
|
||||||
|
## 1. What exists
|
||||||
|
|
||||||
|
- **Inference** ([inference.md §2](inference.md)). The macOS ladder is CoreML, then ONNX
|
||||||
|
Runtime's CPU provider, then tract. CoreML is unmeasured. The probe decides whether it is used,
|
||||||
|
and the crash guard (§4, `attempt`) covers the case where the provider takes the process down.
|
||||||
|
The device fingerprint is the chip (`machdep.cpu.brand_string`) and the OS release, because
|
||||||
|
CoreML ships with the OS.
|
||||||
|
- **Where files go** ([`dr_plat::dirs`](../../platform/dr-plat/src/dirs.rs)). The Unix rules,
|
||||||
|
except the state directory (the log and crash records), which is `~/Library/Logs/darkroom`.
|
||||||
|
- **A diagnostic build**, described in §3.
|
||||||
|
|
||||||
|
The rest is not built, packaged or run on macOS by anyone here. This covers the window,
|
||||||
|
Metal through wgpu, the display profile (FR-DSP-8 asks X11 and Wayland), the keyring, the
|
||||||
|
bundle, and signing. `dr-plat` sends every non-Android Unix to the X11/Wayland dependencies.
|
||||||
|
|
||||||
|
## 2. Building
|
||||||
|
|
||||||
|
`docker/macos` compiles and links for macOS from Linux, using zig as the linker
|
||||||
|
(`cargo-zigbuild`). Zig carries libSystem's stubs and the C headers, so tract's SIMD kernels
|
||||||
|
compile and anything that needs only libSystem links:
|
||||||
|
|
||||||
|
./docker/macos/build.sh cargo zigbuild --target aarch64-apple-darwin -p dr-inference-engine --features native --all-targets
|
||||||
|
./docker/macos/build.sh cargo-zigbuild clippy --target aarch64-apple-darwin -p dr-inference-engine --features native --all-targets -- -D warnings
|
||||||
|
|
||||||
|
That produces Mach-O arm64 test binaries and the `ladder` and `ep_probe` examples. Nothing runs
|
||||||
|
them. Anything that links an Apple framework needs the Xcode SDK, which zig does not carry. That
|
||||||
|
includes `dr-plat` (through the keyring's Security and CoreFoundation) and so the desktop app, and
|
||||||
|
its link fails with `unable to find framework`. `cargo check` for those still works in the
|
||||||
|
container.
|
||||||
|
|
||||||
|
Linking the app needs Apple's SDK, which means a Mac. On one:
|
||||||
|
|
||||||
|
cargo build --profile diagnostic -p darkroom-desktop
|
||||||
|
./tools/fetch-desktop-runtime.sh # ONNX Runtime 1.29.0 with CoreML, Apple silicon only
|
||||||
|
|
||||||
|
The fetch script puts `libonnxruntime.dylib` in the user's `runtime/` directory, next to the
|
||||||
|
models. The app also looks in `Contents/Frameworks` of its own bundle, and in Homebrew's
|
||||||
|
`/opt/homebrew/lib` and `/usr/local/lib`. Homebrew's build may not include CoreML; the probe
|
||||||
|
reports that as a failed rung and uses the CPU.
|
||||||
|
|
||||||
|
**For whoever packages it.** A notarised app runs with the hardened runtime, whose library
|
||||||
|
validation refuses to `dlopen` a library signed by another team. A bundled
|
||||||
|
`Contents/Frameworks/libonnxruntime.dylib` must be signed with the app. A runtime the user
|
||||||
|
fetched needs the `com.apple.security.cs.disable-library-validation` entitlement, or it will not
|
||||||
|
load, and the app will be the tract build without saying why beyond one log line.
|
||||||
|
|
||||||
|
## 3. The diagnostic build
|
||||||
|
|
||||||
|
Every macOS build is in the hands of someone who can send a log but cannot attach a debugger,
|
||||||
|
so it is set up to log like a debug build while running at release speed.
|
||||||
|
|
||||||
|
- **The log says more.** With no `RUST_LOG`, the desktop's default filter is `debug` for every
|
||||||
|
`dr_*` crate, for `darkroom_desktop`, and for `onnxruntime`. That last one is ONNX Runtime's
|
||||||
|
own session log, which the engine forwards into `log` on every platform (`session.rs`,
|
||||||
|
`with_runtime_log`). At `debug` it includes how many nodes each provider took. At `trace`
|
||||||
|
(`RUST_LOG=onnxruntime=trace`) it lists every node's placement, which is long. The log cap is
|
||||||
|
the same as everywhere (two files of 4 MiB).
|
||||||
|
- **Backtraces have line numbers.** `--profile diagnostic` is release plus line tables. On
|
||||||
|
macOS the tables go into a `.dSYM` beside the executable, and the backtrace in a crash record
|
||||||
|
finds them only if the `.dSYM` stays next to the binary. Keep it in the bundle.
|
||||||
|
|
||||||
|
## 4. What to ask a Mac user for
|
||||||
|
|
||||||
|
`~/Library/Logs/darkroom/darkroom.log`, plus `darkroom.log.1` if present, after the first launch
|
||||||
|
and after the first scan with faces. Console.app lists it under *Log Reports*. The Settings
|
||||||
|
diagnostics bundle collects the same files. The lines that answer the open questions are:
|
||||||
|
|
||||||
|
| Line | What it tells us |
|
||||||
|
|---|---|
|
||||||
|
| `inference: ONNX Runtime … from …` / `inference: runtime tract` | Whether a runtime was found, and which one |
|
||||||
|
| `inference: floor … ms on the CPU provider` | The CPU number for §2's table |
|
||||||
|
| `inference: CoreML session built in … s` | CoreML's first compile of the probe model |
|
||||||
|
| `inference: CoreML rejected: …` / `failed: …` | Why the CPU was kept |
|
||||||
|
| `onnxruntime` lines naming `CoreMLExecutionProvider::GetCapability` | How much of the graph CoreML took |
|
||||||
|
| `inference: the app died during …` | The crash guard fired, and on what |
|
||||||
|
| `inference: compiling … for CoreML` / `ready on CoreML in … s` | Each model's compile, and any that CoreML refused |
|
||||||
|
|
||||||
|
Also ask for the settings row (*Settings › About › Inference*), which is one line and says the
|
||||||
|
same in short. When one of these logs comes back with CoreML numbers, they go into
|
||||||
|
[inference.md §1–2](inference.md), and footnote ⁵ becomes a measurement.
|
||||||
+4
-35
@@ -367,7 +367,7 @@ Built, on branch `merge/panorama`, in the order §10 gave:
|
|||||||
| The camera-space tap | `OutputMode::CameraLinear`, `AdjustPass::render_camera_linear` | Done, `rgba32float`, tiles by view rect |
|
| The camera-space tap | `OutputMode::CameraLinear`, `AdjustPass::render_camera_linear` | Done, `rgba32float`, tiles by view rect |
|
||||||
| Linear DNG writer, streamed | `dr_export::write_linear_dng` | Done; rawler reads it back |
|
| Linear DNG writer, streamed | `dr_export::write_linear_dng` | Done; rawler reads it back |
|
||||||
| A three-sample `RawImage` re-entering the pipeline | `dr-decode`, `DemosaicedImage::from_linear_rgb16` | Done |
|
| A three-sample `RawImage` re-entering the pipeline | `dr-decode`, `DemosaicedImage::from_linear_rgb16` | Done |
|
||||||
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; seams (§11.1) over a feather, scalar gain |
|
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; feathered blend, scalar gain |
|
||||||
| The job: load, proxies, align, gains, confirm, merge, provenance | `dr_ui::merge` | Done; `examples/merge.rs` drives it headless |
|
| The job: load, proxies, align, gains, confirm, merge, provenance | `dr_ui::merge` | Done; `examples/merge.rs` drives it headless |
|
||||||
| The page: table, preview, projection, Merge/Stop/Back; the grid's button | `merge.slint`, `merge_ui.rs` | Done; `DARKROOM_START_MERGE=a.CR2,b.CR2` lands on it |
|
| The page: table, preview, projection, Merge/Stop/Back; the grid's button | `merge.slint`, `merge_ui.rs` | Done; `DARKROOM_START_MERGE=a.CR2,b.CR2` lands on it |
|
||||||
| Placement beside the sources through the outbox, rescan | `merge_ui.rs` | Done, untested against a server |
|
| Placement beside the sources through the outbox, rescan | `merge_ui.rs` | Done, untested against a server |
|
||||||
@@ -385,11 +385,9 @@ half.
|
|||||||
the file carries the black border. The largest inscribed rectangle over
|
the file carries the black border. The largest inscribed rectangle over
|
||||||
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
|
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
|
||||||
nothing is thrown away and the develop view opens on the picture.
|
nothing is thrown away and the develop view opens on the picture.
|
||||||
2. **The pyramid** (§10 step 5). Seams landed 2026-09-30 (§11.1); the
|
2. **Seams and the pyramid** (§10 step 5). The feather hides exposure and
|
||||||
blend across them is one width for every frequency, so an exposure step
|
small misalignment; parallax on the near slope will show as a soft
|
||||||
the gains leave is narrowed to the seam's 64 px rather than hidden over
|
double edge at 1:1.
|
||||||
the old 200. A Laplacian pyramid would blend low frequencies wide and
|
|
||||||
detail narrow.
|
|
||||||
3. **Vignetting in the tap.** The lens profile's distortion is applied
|
3. **Vignetting in the tap.** The lens profile's distortion is applied
|
||||||
before the fetch; its vignetting is an operation and is not. Frame edges
|
before the fetch; its vignetting is an operation and is not. Frame edges
|
||||||
are darker than their centres by the lens's falloff, and the feather
|
are darker than their centres by the lens's falloff, and the feather
|
||||||
@@ -404,35 +402,6 @@ half.
|
|||||||
catalog's `content_hash` is null for most images most of the time. The
|
catalog's `content_hash` is null for most images most of the time. The
|
||||||
hash can join it when the catalog has one.
|
hash can join it when the catalog has one.
|
||||||
|
|
||||||
### 11.1 Seams — 2026-09-30
|
|
||||||
|
|
||||||
The feather averaged every overlap over 200 px, so anything the frames
|
|
||||||
disagreed on — parallax on the near slope, a walker, wind in a branch — came
|
|
||||||
out twice at half strength: a soft double edge at 1:1, reported as a glitch.
|
|
||||||
|
|
||||||
`dr_pano::seam` now chooses, per output texel at proxy resolution, which
|
|
||||||
frame it is taken from. Frames are laid down nearest-first; where a new one
|
|
||||||
overlaps the composite, each texel costs the gain-corrected difference
|
|
||||||
between the two, plus the detail either has there, plus nearness to either
|
|
||||||
frame's edge (vignetting, the lens correction's fringe), taken as the
|
|
||||||
**worst** over a 4-texel window so the path stays a blend radius clear of a
|
|
||||||
difference rather than grazing it. The cut is a dynamic-programming path
|
|
||||||
across the overlap, perpendicular to the line from the composite's frames to
|
|
||||||
the new one: §4's per-column seam, not a graph cut. The map is computed per
|
|
||||||
projection, for the page's preview and again for the merge.
|
|
||||||
|
|
||||||
`merge.wgsl` weights a frame by its tent-filtered share of the label map
|
|
||||||
about each pixel (`SeamMap::share`, repeated verbatim), over a window
|
|
||||||
`seam_blend_px` wide (64, capped at 4 texels either side). The edge feather
|
|
||||||
remains underneath as a factor and, with a 1e-4 floor, as the answer where
|
|
||||||
the map names no frame that reaches the pixel. `--feather-only` on
|
|
||||||
`examples/merge.rs` merges the old way, for comparison.
|
|
||||||
|
|
||||||
Known limits: one axis per new frame, so in a multi-row set a frame
|
|
||||||
overlapping its left neighbour and the row above is cut along a compromise
|
|
||||||
direction; the cost reads grey proxies, so a difference in hue alone is
|
|
||||||
invisible to it.
|
|
||||||
|
|
||||||
## 12. Filling the border instead of cropping it — MI-GAN, read and measured 2026-09-19
|
## 12. Filling the border instead of cropping it — MI-GAN, read and measured 2026-09-19
|
||||||
|
|
||||||
Raised after the first merges: the ragged border a cylinder leaves could be
|
Raised after the first merges: the ragged border a cylinder leaves could be
|
||||||
|
|||||||
+86
-86
File diff suppressed because one or more lines are too long
+24
-24
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2029`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2025`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2125`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2121`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2216`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2212`</sub>
|
||||||
|
|
||||||
### Open this list
|
### Open this list
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
|||||||
|
|
||||||
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2442`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2438`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2472`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2468`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
||||||
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2482`</sub>
|
||||||
|
|
||||||
### Remove a repair
|
### Remove a repair
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, then Delete Repair
|
- **Pointer** — Click it, then Delete Repair
|
||||||
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2506`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2502`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2521`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2538`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2534`</sub>
|
||||||
|
|
||||||
### Choose which kinds of edit a copy carries
|
### Choose which kinds of edit a copy carries
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2556`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2552`</sub>
|
||||||
|
|
||||||
### Export this photograph as the last one was
|
### Export this photograph as the last one was
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
|
|||||||
|
|
||||||
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2581`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2577`</sub>
|
||||||
|
|
||||||
### Choose how to export, then export
|
### Choose how to export, then export
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
|
|||||||
|
|
||||||
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2594`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2590`</sub>
|
||||||
|
|
||||||
### Keep a crop that leaves a mask outside
|
### Keep a crop that leaves a mask outside
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||||
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2659`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2655`</sub>
|
||||||
|
|
||||||
### Go back to the grid
|
### Go back to the grid
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
|
|
||||||
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2676`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2672`</sub>
|
||||||
|
|
||||||
### Nudge the control last moved
|
### Nudge the control last moved
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
|
|||||||
|
|
||||||
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2705`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2701`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2733`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2729`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2769`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2765`</sub>
|
||||||
|
|
||||||
### Rate this photograph
|
### Rate this photograph
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
- **Pointer** — Click a star in the top bar
|
- **Pointer** — Click a star in the top bar
|
||||||
- **Keyboard** — `0`–`5`
|
- **Keyboard** — `0`–`5`
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2826`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2822`</sub>
|
||||||
|
|
||||||
### Pick or reject this photograph
|
### Pick or reject this photograph
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2832`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2828`</sub>
|
||||||
|
|
||||||
### Give this photograph a colour label
|
### Give this photograph a colour label
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2862`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2858`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2887`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2883`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:3012`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:3008`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
|
|||||||
|
|
||||||
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1026`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1024`</sub>
|
||||||
|
|
||||||
### Do what a sheet offers
|
### Do what a sheet offers
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
|
|||||||
- **Pointer** — Press its button — Export, or Copy
|
- **Pointer** — Press its button — Export, or Copy
|
||||||
- **Keyboard** — `Enter`, on the export and copy sheets
|
- **Keyboard** — `Enter`, on the export and copy sheets
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1036`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1034`</sub>
|
||||||
|
|
||||||
### Scroll by the scrollbar
|
### Scroll by the scrollbar
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.19.3
|
pkgver=0.19.2
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ pub fn set_state_dir(dir: PathBuf) {
|
|||||||
|
|
||||||
/// Where this application keeps state that is neither configuration nor cache.
|
/// Where this application keeps state that is neither configuration nor cache.
|
||||||
///
|
///
|
||||||
/// `$XDG_STATE_HOME/darkroom`, falling back to `~/.local/state/darkroom`.
|
/// `$XDG_STATE_HOME/darkroom`, falling back to `~/.local/state/darkroom` —
|
||||||
|
/// `~/Library/Logs/darkroom` on macOS (`dirs`).
|
||||||
/// State rather than cache because a crash record must survive the sweep that
|
/// State rather than cache because a crash record must survive the sweep that
|
||||||
/// a cache directory exists to permit, and rather than config because it is
|
/// a cache directory exists to permit, and rather than config because it is
|
||||||
/// not something the user edits.
|
/// not something the user edits.
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
//!
|
//!
|
||||||
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
||||||
//! `~/.local/state/darkroom/darkroom.log`.
|
//! `~/.local/state/darkroom/darkroom.log`.
|
||||||
|
//! * macOS: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
||||||
|
//! `~/Library/Logs/darkroom/darkroom.log`, where Console.app lists it.
|
||||||
//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`,
|
//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`,
|
||||||
//! which `adb pull` reads from an ordinary release build. See
|
//! which `adb pull` reads from an ordinary release build. See
|
||||||
//! [`crate::state`] for why not the internal directory, and
|
//! [`crate::state`] for why not the internal directory, and
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
|
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
|
||||||
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
|
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
|
||||||
//! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` |
|
//! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` |
|
||||||
|
//! | macOS default | `~/.config` | `~/.local/share` | `~/Library/Logs` |
|
||||||
|
//!
|
||||||
|
//! macOS follows the Unix rules except for the one directory a user is asked
|
||||||
|
//! to find by hand: the log. Finder hides `~/.local`, and `~/Library/Logs`
|
||||||
|
//! is where Console.app and a Mac user already look (docs/dev/macos.md).
|
||||||
//!
|
//!
|
||||||
//! then `darkroom` under each. Config roams on Windows and the rest does not,
|
//! then `darkroom` under each. Config roams on Windows and the rest does not,
|
||||||
//! which is the same split XDG makes between config and everything else, and
|
//! which is the same split XDG makes between config and everything else, and
|
||||||
@@ -100,11 +105,18 @@ fn resolve(kind: Base, env: impl Fn(&str) -> Option<OsString>) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where state goes under `$HOME` when `XDG_STATE_HOME` does not say.
|
||||||
|
const STATE_UNDER_HOME: &str = if cfg!(target_os = "macos") {
|
||||||
|
"Library/Logs"
|
||||||
|
} else {
|
||||||
|
".local/state"
|
||||||
|
};
|
||||||
|
|
||||||
fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||||
let (var, under_home) = match kind {
|
let (var, under_home) = match kind {
|
||||||
Base::Config => ("XDG_CONFIG_HOME", ".config"),
|
Base::Config => ("XDG_CONFIG_HOME", ".config"),
|
||||||
Base::Data => ("XDG_DATA_HOME", ".local/share"),
|
Base::Data => ("XDG_DATA_HOME", ".local/share"),
|
||||||
Base::State => ("XDG_STATE_HOME", ".local/state"),
|
Base::State => ("XDG_STATE_HOME", STATE_UNDER_HOME),
|
||||||
};
|
};
|
||||||
absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home)))
|
absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home)))
|
||||||
}
|
}
|
||||||
@@ -150,7 +162,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
xdg_base(Base::State, &e),
|
xdg_base(Base::State, &e),
|
||||||
Some(PathBuf::from("/home/someone/.local/state"))
|
Some(PathBuf::from("/home/someone").join(STATE_UNDER_HOME))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +174,7 @@ mod tests {
|
|||||||
let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]);
|
let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
xdg_base(Base::State, &e),
|
xdg_base(Base::State, &e),
|
||||||
Some(PathBuf::from("/home/someone/.local/state"))
|
Some(PathBuf::from("/home/someone").join(STATE_UNDER_HOME))
|
||||||
);
|
);
|
||||||
assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None);
|
assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! FR-CAT-10 asks for removable-volume insertion to be detected "where the
|
//! FR-CAT-10 asks for removable-volume insertion to be detected "where the
|
||||||
//! platform permits", which is a careful phrase and this module is why. There
|
//! platform permits", which is a careful phrase and this module is why. There
|
||||||
//! is no portable answer: Linux has a mount table and a sysfs flag, Android
|
//! is no portable answer: Linux has a mount table and a sysfs flag, Android
|
||||||
//! has neither and lists its volumes through a Java service (`dr_ui::cards`).
|
//! has neither and hands out a document tree the user picked (ARCH §6.9).
|
||||||
//! So this reports what it can and returns an empty list where it cannot,
|
//! So this reports what it can and returns an empty list where it cannot,
|
||||||
//! and every caller must still offer the user a way to say where the card is.
|
//! and every caller must still offer the user a way to say where the card is.
|
||||||
//!
|
//!
|
||||||
@@ -67,18 +67,22 @@ impl Volume {
|
|||||||
/// where it is mounted. `false` here means the operation cannot be performed
|
/// where it is mounted. `false` here means the operation cannot be performed
|
||||||
/// however hard the user tries, and the interface should not offer it.
|
/// however hard the user tries, and the interface should not offer it.
|
||||||
///
|
///
|
||||||
/// It is `true` on Linux and on Android. On Android the card is read by
|
/// It is `false` on Android, for two reasons that both have to be fixed before
|
||||||
/// path too — `/storage/9C33-6BBD` — once the user has granted "all files
|
/// it can change:
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// The engine above this takes storage traits and never a path, and the
|
/// - There is no mount table to read and no path to type. Storage is reached
|
||||||
/// importer only ever *writes* into its own staging directory, which is a
|
/// through a tree the user granted, and a removable volume appears there or
|
||||||
/// plain directory on every platform. So what this gates is whether a card
|
/// not at all (ARCH §6.9).
|
||||||
/// can be *read*, nothing more.
|
/// - 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.
|
||||||
pub const fn imports_supported() -> bool {
|
pub const fn imports_supported() -> bool {
|
||||||
cfg!(any(target_os = "linux", target_os = "android"))
|
cfg!(target_os = "linux")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every mounted volume that might hold photographs.
|
/// Every mounted volume that might hold photographs.
|
||||||
@@ -126,9 +130,9 @@ fn platform_volumes() -> Vec<Volume> {
|
|||||||
|
|
||||||
/// Everywhere else: no answer, and saying so is the honest result.
|
/// Everywhere else: no answer, and saying so is the honest result.
|
||||||
///
|
///
|
||||||
/// On Android there is no readable mount table either; the volumes come from
|
/// On Android the question is not merely unanswerable but wrong — storage is
|
||||||
/// `StorageManager` through `dr_ui::cards`, which needs the JNI this crate
|
/// reached through a tree the user granted, and a card appears there or not at
|
||||||
/// does not have.
|
/// all (ARCH §6.9, FR-PLAT-AND-1).
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn platform_volumes() -> Vec<Volume> {
|
fn platform_volumes() -> Vec<Volume> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -354,10 +358,7 @@ tmpfs /run/user/1000 tmpfs rw,nosuid 0 0
|
|||||||
// The two are different claims: an empty list means "plug one in",
|
// The two are different claims: an empty list means "plug one in",
|
||||||
// `false` here means "this cannot be done here". An interface that
|
// `false` here means "this cannot be done here". An interface that
|
||||||
// conflated them would offer a page that can never be used.
|
// conflated them would offer a page that can never be used.
|
||||||
assert_eq!(
|
assert_eq!(imports_supported(), cfg!(target_os = "linux"));
|
||||||
imports_supported(),
|
|
||||||
cfg!(any(target_os = "linux", target_os = "android"))
|
|
||||||
);
|
|
||||||
if !imports_supported() {
|
if !imports_supported() {
|
||||||
assert!(volumes().is_empty());
|
assert!(volumes().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,32 @@
|
|||||||
# directory (docs/inference.md §1.3).
|
# directory (docs/inference.md §1.3).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
DEST="${1:-${XDG_DATA_HOME:-${HOME}/.local/share}/darkroom/runtime}"
|
DEST="${1:-${XDG_DATA_HOME:-${HOME}/.local/share}/darkroom/runtime}"
|
||||||
|
|
||||||
|
# macOS: Microsoft's release archive, which carries the CoreML provider in the
|
||||||
|
# one library. Pinned, because the CoreML options the engine sets were read
|
||||||
|
# from this version's source (docs/dev/macos.md, CLAUDE.md "Providers").
|
||||||
|
# Apple silicon only: no Intel archive is published since 1.29; an Intel Mac
|
||||||
|
# takes Homebrew's `onnxruntime` or stays on tract.
|
||||||
|
if [[ "$(uname -s)" == Darwin ]]; then
|
||||||
|
ORT_VERSION=1.29.0
|
||||||
|
[[ "$(uname -m)" == arm64 ]] || {
|
||||||
|
echo "error: no ONNX Runtime ${ORT_VERSION} archive for $(uname -m); try: brew install onnxruntime" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
NAME="onnxruntime-osx-arm64-${ORT_VERSION}"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${WORK}"' EXIT
|
||||||
|
echo "==> downloading ${NAME}"
|
||||||
|
curl -fsSL "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/${NAME}.tgz" \
|
||||||
|
| tar xz -C "${WORK}"
|
||||||
|
mkdir -p "${DEST}"
|
||||||
|
cp "${WORK}/${NAME}/lib/libonnxruntime.dylib" "${WORK}/${NAME}/LICENSE" "${DEST}/"
|
||||||
|
echo "==> runtime in ${DEST}:"
|
||||||
|
ls -1 "${DEST}" | sed 's/^/ /'
|
||||||
|
echo " (the app finds it on its next launch; Settings › About › Inference says what it chose)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
WORK="$(mktemp -d -p /var/tmp fetch-desktop-runtime.XXXXXX)"
|
WORK="$(mktemp -d -p /var/tmp fetch-desktop-runtime.XXXXXX)"
|
||||||
trap 'rm -rf "${WORK}"' EXIT
|
trap 'rm -rf "${WORK}"' EXIT
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! `--feather-only` merges without seams — every overlap the feathered
|
|
||||||
//! average — for comparing the two on the same set.
|
|
||||||
//!
|
|
||||||
//! `--leave-out N` unticks frame `N` once the first alignment is in, as the
|
//! `--leave-out N` unticks frame `N` once the first alignment is in, as the
|
||||||
//! page's box would, so the job aligns again without it.
|
//! page's box would, so the job aligns again without it.
|
||||||
//!
|
//!
|
||||||
@@ -28,7 +25,6 @@ fn main() {
|
|||||||
let mut fill = false;
|
let mut fill = false;
|
||||||
let mut wait_engines = false;
|
let mut wait_engines = false;
|
||||||
let mut leave_out: Option<usize> = None;
|
let mut leave_out: Option<usize> = None;
|
||||||
let mut seams = true;
|
|
||||||
loop {
|
loop {
|
||||||
match args.first().map(String::as_str) {
|
match args.first().map(String::as_str) {
|
||||||
Some("--out") => {
|
Some("--out") => {
|
||||||
@@ -43,10 +39,6 @@ fn main() {
|
|||||||
args.remove(0);
|
args.remove(0);
|
||||||
leave_out = args.remove(0).parse().ok();
|
leave_out = args.remove(0).parse().ok();
|
||||||
}
|
}
|
||||||
Some("--feather-only") => {
|
|
||||||
args.remove(0);
|
|
||||||
seams = false;
|
|
||||||
}
|
|
||||||
Some("--wait-engines") => {
|
Some("--wait-engines") => {
|
||||||
args.remove(0);
|
args.remove(0);
|
||||||
wait_engines = true;
|
wait_engines = true;
|
||||||
@@ -109,7 +101,6 @@ fn main() {
|
|||||||
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||||
// The filler from the shared models directory, as the app finds it.
|
// The filler from the shared models directory, as the app finds it.
|
||||||
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
||||||
request.seams = seams;
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let (decide, decision) = std::sync::mpsc::channel();
|
let (decide, decision) = std::sync::mpsc::channel();
|
||||||
let cancel = dr_ui::merge::Cancel::default();
|
let cancel = dr_ui::merge::Cancel::default();
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-100
@@ -68,13 +68,6 @@ pub struct ImportController {
|
|||||||
selected: Cell<i32>,
|
selected: Cell<i32>,
|
||||||
/// The source. Typed or chosen; a card the app did not find is still a card.
|
/// The source. Typed or chosen; a card the app did not find is still a card.
|
||||||
card: RefCell<String>,
|
card: RefCell<String>,
|
||||||
/// Android only: the user has not granted "all files access", so no card
|
|
||||||
/// can be read and the page asks for it instead of listing volumes.
|
|
||||||
needs_access: Cell<bool>,
|
|
||||||
/// Android only: watches for that grant after the settings page has been
|
|
||||||
/// opened, so the list fills in when the user comes back.
|
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
||||||
access_watch: RefCell<Option<slint::Timer>>,
|
|
||||||
|
|
||||||
/// What the last survey found, `None` before one has run.
|
/// What the last survey found, `None` before one has run.
|
||||||
survey: RefCell<Option<(usize, u64)>>,
|
survey: RefCell<Option<(usize, u64)>>,
|
||||||
@@ -113,8 +106,6 @@ impl ImportController {
|
|||||||
volumes: RefCell::new(Vec::new()),
|
volumes: RefCell::new(Vec::new()),
|
||||||
selected: Cell::new(-1),
|
selected: Cell::new(-1),
|
||||||
card: RefCell::new(String::new()),
|
card: RefCell::new(String::new()),
|
||||||
needs_access: Cell::new(false),
|
|
||||||
access_watch: RefCell::new(None),
|
|
||||||
survey: RefCell::new(None),
|
survey: RefCell::new(None),
|
||||||
surveying: Cell::new(false),
|
surveying: Cell::new(false),
|
||||||
upload_target: RefCell::new(String::new()),
|
upload_target: RefCell::new(String::new()),
|
||||||
@@ -146,7 +137,7 @@ impl ImportController {
|
|||||||
|
|
||||||
/// Look for cards. Cheap, and safe to call whenever the page is shown.
|
/// Look for cards. Cheap, and safe to call whenever the page is shown.
|
||||||
fn refresh_volumes(&self) {
|
fn refresh_volumes(&self) {
|
||||||
let found = self.find_volumes();
|
let found = dr_plat::volumes();
|
||||||
// Keep a typed path: a refresh must not discard what the user entered
|
// Keep a typed path: a refresh must not discard what the user entered
|
||||||
// because the app happened to find three other volumes.
|
// because the app happened to find three other volumes.
|
||||||
if self.card.borrow().is_empty() {
|
if self.card.borrow().is_empty() {
|
||||||
@@ -169,34 +160,6 @@ impl ImportController {
|
|||||||
*self.volumes.borrow_mut() = found;
|
*self.volumes.borrow_mut() = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mount table, on the desktop.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
|
|
||||||
dr_plat::volumes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `StorageManager`, on Android, once the user has allowed the app to read
|
|
||||||
/// a card at all (see [`crate::cards`]).
|
|
||||||
///
|
|
||||||
/// The path cannot be typed here — the field is hidden on Android — so a
|
|
||||||
/// remembered path whose card has been taken out is dropped rather than
|
|
||||||
/// left selected with no way to change it.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
|
|
||||||
let granted = crate::cards::has_access();
|
|
||||||
self.needs_access.set(!granted);
|
|
||||||
let found = if granted {
|
|
||||||
crate::cards::volumes()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
let card = self.card.borrow().clone();
|
|
||||||
if !found.iter().any(|v| v.path.display().to_string() == card) {
|
|
||||||
self.card.borrow_mut().clear();
|
|
||||||
}
|
|
||||||
found
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The transfer options a run is started with.
|
/// The transfer options a run is started with.
|
||||||
fn ingest_options(&self) -> Options {
|
fn ingest_options(&self) -> Options {
|
||||||
let stored = self.options();
|
let stored = self.options();
|
||||||
@@ -276,7 +239,6 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
|
|||||||
!card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)),
|
!card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)),
|
||||||
);
|
);
|
||||||
window.set_import_card_path(card.into());
|
window.set_import_card_path(card.into());
|
||||||
window.set_import_needs_access(ctl.needs_access.get());
|
|
||||||
|
|
||||||
window.set_import_surveying(ctl.surveying.get());
|
window.set_import_surveying(ctl.surveying.get());
|
||||||
window.set_import_survey_summary(
|
window.set_import_survey_summary(
|
||||||
@@ -466,20 +428,6 @@ where
|
|||||||
render(&w, &ctl);
|
render(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
|
||||||
let weak = window.as_weak();
|
|
||||||
let ctl = ctl.clone();
|
|
||||||
let context = context.clone();
|
|
||||||
window.on_import_grant_access(move || {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
watch_for_access(&weak, &ctl, &context);
|
|
||||||
// Nothing to grant elsewhere; the control is shown only when
|
|
||||||
// `needs_access` is set, which only Android sets.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let _ = (&weak, &ctl, &context);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options.
|
/// Options.
|
||||||
@@ -619,53 +567,6 @@ fn wire_running<C, F>(
|
|||||||
|
|
||||||
/// Take a source the user named — typed, or chosen in the dialogue — rather
|
/// Take a source the user named — typed, or chosen in the dialogue — rather
|
||||||
/// than one from the volume list, and count what is on it.
|
/// than one from the volume list, and count what is on it.
|
||||||
/// Send the user to the system page that grants "all files access", and
|
|
||||||
/// fill the page in once they have.
|
|
||||||
///
|
|
||||||
/// The grant is a settings switch, not a dialog with an answer, and nothing
|
|
||||||
/// tells the app when it flips. So the page asks again twice a second until
|
|
||||||
/// it has it, or for five minutes — long enough to find the switch, short
|
|
||||||
/// enough that a user who walked away is not polled for ever. Coming back to
|
|
||||||
/// the page later re-checks anyway (`import_open`).
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn watch_for_access<C>(weak: &slint::Weak<AppWindow>, ctl: &Rc<ImportController>, context: &Rc<C>)
|
|
||||||
where
|
|
||||||
C: Fn() -> Option<Context> + 'static,
|
|
||||||
{
|
|
||||||
crate::cards::request_access();
|
|
||||||
|
|
||||||
let since = std::time::Instant::now();
|
|
||||||
let (weak, held, context) = (weak.clone(), ctl.clone(), context.clone());
|
|
||||||
let timer = slint::Timer::default();
|
|
||||||
timer.start(
|
|
||||||
slint::TimerMode::Repeated,
|
|
||||||
std::time::Duration::from_millis(500),
|
|
||||||
move || {
|
|
||||||
let granted = crate::cards::has_access();
|
|
||||||
if !granted && since.elapsed() < std::time::Duration::from_secs(300) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(t) = held.access_watch.borrow().as_ref() {
|
|
||||||
t.stop();
|
|
||||||
}
|
|
||||||
// Released outside its own callback, as `saf::pick_tree` does: a
|
|
||||||
// timer dropped from inside the closure it is running is dropping
|
|
||||||
// that closure mid-call.
|
|
||||||
let release = held.clone();
|
|
||||||
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
|
|
||||||
release.access_watch.borrow_mut().take();
|
|
||||||
});
|
|
||||||
if granted {
|
|
||||||
let Some(w) = weak.upgrade() else { return };
|
|
||||||
held.refresh_volumes();
|
|
||||||
survey(&w, &held, &context);
|
|
||||||
render(&w, &held);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
*ctl.access_watch.borrow_mut() = Some(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_card(
|
fn set_card(
|
||||||
w: &AppWindow,
|
w: &AppWindow,
|
||||||
ctl: &Rc<ImportController>,
|
ctl: &Rc<ImportController>,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ mod albums_ui;
|
|||||||
#[cfg(all(feature = "automation", unix))]
|
#[cfg(all(feature = "automation", unix))]
|
||||||
mod automation;
|
mod automation;
|
||||||
mod bursts;
|
mod bursts;
|
||||||
mod cards;
|
|
||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod decoder_seam;
|
mod decoder_seam;
|
||||||
|
|||||||
@@ -522,16 +522,11 @@ pub(super) fn flush_sweep(catalog: &Catalog, found: &mut Vec<MetadataFound>) {
|
|||||||
.filter(|m| m.captured_at.is_none())
|
.filter(|m| m.captured_at.is_none())
|
||||||
.map(|m| m.image_id)
|
.map(|m| m.image_id)
|
||||||
.collect();
|
.collect();
|
||||||
for &id in &ids {
|
for id in ids {
|
||||||
let _ = catalog
|
let _ = catalog
|
||||||
.connection()
|
.connection()
|
||||||
.execute("UPDATE images SET metadata_state = 2 WHERE id = ?1", [id]);
|
.execute("UPDATE images SET metadata_state = 2 WHERE id = ?1", [id]);
|
||||||
}
|
}
|
||||||
// A header with no date is not a photograph with no date: WhatsApp and
|
|
||||||
// most re-exports strip EXIF and keep the date in the name.
|
|
||||||
if let Err(e) = dr_catalog::name_dates::fill(catalog.connection(), Some(&ids)) {
|
|
||||||
log::warn!("sweep: dating from names: {e}");
|
|
||||||
}
|
|
||||||
found.clear();
|
found.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-74
@@ -42,7 +42,7 @@ use dr_gpu::{
|
|||||||
};
|
};
|
||||||
use dr_pano::bundle::Cameras;
|
use dr_pano::bundle::Cameras;
|
||||||
use dr_pano::projection::{self, Projection};
|
use dr_pano::projection::{self, Projection};
|
||||||
use dr_pano::{Alignment, Gray, SeamMap};
|
use dr_pano::{Alignment, Gray};
|
||||||
use dr_pipeline::EditGraph;
|
use dr_pipeline::EditGraph;
|
||||||
|
|
||||||
pub use crate::export::Cancel;
|
pub use crate::export::Cancel;
|
||||||
@@ -103,14 +103,8 @@ pub struct MergeRequest {
|
|||||||
pub destination: MergeDestination,
|
pub destination: MergeDestination,
|
||||||
/// `None` for the projection the field of view suggests.
|
/// `None` for the projection the field of view suggests.
|
||||||
pub projection: Option<Projection>,
|
pub projection: Option<Projection>,
|
||||||
/// Pixels over which a frame's weight ramps up from its edge, where no
|
/// Pixels over which a frame's weight ramps up from its edge.
|
||||||
/// seam says which frame a pixel is taken from.
|
|
||||||
pub feather_px: f32,
|
pub feather_px: f32,
|
||||||
/// Whether overlaps are cut along seams (`dr_pano::seam`) or averaged
|
|
||||||
/// across `feather_px` everywhere.
|
|
||||||
pub seams: bool,
|
|
||||||
/// The width, in output pixels, of the blend across a seam.
|
|
||||||
pub seam_blend_px: f32,
|
|
||||||
/// GPU work unit; also the DNG strip height.
|
/// GPU work unit; also the DNG strip height.
|
||||||
pub chunk: (u32, u32),
|
pub chunk: (u32, u32),
|
||||||
/// The border filler's weights, if the device has them (FR-MRG-4).
|
/// The border filler's weights, if the device has them (FR-MRG-4).
|
||||||
@@ -128,8 +122,6 @@ impl MergeRequest {
|
|||||||
destination,
|
destination,
|
||||||
projection: None,
|
projection: None,
|
||||||
feather_px: 200.0,
|
feather_px: 200.0,
|
||||||
seams: true,
|
|
||||||
seam_blend_px: 64.0,
|
|
||||||
chunk: (2048, 512),
|
chunk: (2048, 512),
|
||||||
inpaint_model: None,
|
inpaint_model: None,
|
||||||
decoder: dr_decode::default(),
|
decoder: dr_decode::default(),
|
||||||
@@ -486,20 +478,11 @@ fn run_inner(
|
|||||||
let preview = match s.alignment.is_complete() {
|
let preview = match s.alignment.is_complete() {
|
||||||
false => None,
|
false => None,
|
||||||
true => {
|
true => {
|
||||||
let map = request
|
|
||||||
.seams
|
|
||||||
.then(|| seams(&proxies, &s.alignment, &s.gains, projection))
|
|
||||||
.flatten();
|
|
||||||
let blend = map.as_deref().map(|m| {
|
|
||||||
let full = m.scale * s.focal_full / s.alignment.focal;
|
|
||||||
(m, m.blend_radius(full, f64::from(request.seam_blend_px)))
|
|
||||||
});
|
|
||||||
let (w, h, mut rgb, mut known) = preview_planes(
|
let (w, h, mut rgb, mut known) = preview_planes(
|
||||||
&colour,
|
&colour,
|
||||||
&proxies,
|
&proxies,
|
||||||
&s.alignment,
|
&s.alignment,
|
||||||
&s.gains,
|
&s.gains,
|
||||||
blend,
|
|
||||||
projection,
|
projection,
|
||||||
first_raw,
|
first_raw,
|
||||||
1600,
|
1600,
|
||||||
@@ -622,13 +605,6 @@ fn run_inner(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The seams, on the surface chosen, over the frames kept.
|
|
||||||
let seam_map = request.seams.then(|| {
|
|
||||||
let kept: Vec<&Gray> = solved.keep.iter().map(|&k| &proxies[k]).collect();
|
|
||||||
seams(&kept, &solved.alignment, &solved.gains, projection)
|
|
||||||
});
|
|
||||||
let seam_map = seam_map.flatten();
|
|
||||||
|
|
||||||
// From here on only the kept frames exist, indexed as the alignment
|
// From here on only the kept frames exist, indexed as the alignment
|
||||||
// indexes them.
|
// indexes them.
|
||||||
let Solved {
|
let Solved {
|
||||||
@@ -728,8 +704,6 @@ fn run_inner(
|
|||||||
scale: focal_full,
|
scale: focal_full,
|
||||||
bounds,
|
bounds,
|
||||||
feather: request.feather_px,
|
feather: request.feather_px,
|
||||||
seams: seam_map.clone(),
|
|
||||||
seam_blend: request.seam_blend_px,
|
|
||||||
chunk: request.chunk,
|
chunk: request.chunk,
|
||||||
sample_scale: white_level as f32,
|
sample_scale: white_level as f32,
|
||||||
balance,
|
balance,
|
||||||
@@ -772,8 +746,6 @@ fn run_inner(
|
|||||||
max_v: bounds.max_v / q,
|
max_v: bounds.max_v / q,
|
||||||
},
|
},
|
||||||
feather: request.feather_px,
|
feather: request.feather_px,
|
||||||
seams: seam_map.clone(),
|
|
||||||
seam_blend: request.seam_blend_px / q as f32,
|
|
||||||
chunk: request.chunk,
|
chunk: request.chunk,
|
||||||
sample_scale: white_level as f32,
|
sample_scale: white_level as f32,
|
||||||
balance,
|
balance,
|
||||||
@@ -1255,51 +1227,21 @@ fn solve(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which frame each part of the composite comes from (`dr_pano::seam`), on
|
|
||||||
/// `projection` at the proxies' scale. `None` when the alignment is not
|
|
||||||
/// complete — the merge then falls back to the feathered average.
|
|
||||||
fn seams(
|
|
||||||
proxies: &[&Gray],
|
|
||||||
alignment: &Alignment,
|
|
||||||
gains: &[f32],
|
|
||||||
projection: Projection,
|
|
||||||
) -> Option<Arc<SeamMap>> {
|
|
||||||
if !alignment.is_complete() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let t = Instant::now();
|
|
||||||
let map = dr_pano::seam::find(
|
|
||||||
proxies,
|
|
||||||
&alignment.cameras(),
|
|
||||||
gains,
|
|
||||||
projection,
|
|
||||||
&dr_pano::SeamOptions::default(),
|
|
||||||
);
|
|
||||||
log::info!(
|
|
||||||
"merge: seams over {} frames in {:?}",
|
|
||||||
proxies.len(),
|
|
||||||
t.elapsed()
|
|
||||||
);
|
|
||||||
map.map(Arc::new)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The aligned set on its surface, in colour, for the page.
|
/// The aligned set on its surface, in colour, for the page.
|
||||||
///
|
///
|
||||||
/// A quick look, not the pipeline: the first frame's white balance and
|
/// A quick look, not the pipeline: the first frame's white balance and
|
||||||
/// matrix, a gamma, and the frames joined along the same seams the merge
|
/// matrix, a gamma, and the frames averaged where they overlap with their
|
||||||
/// will use, with their gains applied. Ghosting here is the alignment's
|
/// gains applied. Ghosting here is the alignment's error and banding is
|
||||||
/// error and banding is the gains', which is exactly what the photographer
|
/// the gains', which is exactly what the photographer is being asked to
|
||||||
/// is being asked to look at. Fitted to 1600 px across.
|
/// look at. Fitted to 1600 px across.
|
||||||
/// The aligned set on its surface, in colour, as planes: `(w, h, rgb 0..1,
|
/// The aligned set on its surface, in colour, as planes: `(w, h, rgb 0..1,
|
||||||
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
||||||
/// a gamma — which is also what the border filler was trained on.
|
/// a gamma — which is also what the border filler was trained on.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn preview_planes(
|
fn preview_planes(
|
||||||
colour: &[&[f32]],
|
colour: &[&[f32]],
|
||||||
proxies: &[&Gray],
|
proxies: &[&Gray],
|
||||||
alignment: &Alignment,
|
alignment: &Alignment,
|
||||||
gains: &[f32],
|
gains: &[f32],
|
||||||
seams: Option<(&SeamMap, f64)>,
|
|
||||||
projection: Projection,
|
projection: Projection,
|
||||||
first: &RawImage,
|
first: &RawImage,
|
||||||
max_width: usize,
|
max_width: usize,
|
||||||
@@ -1322,7 +1264,7 @@ fn preview_planes(
|
|||||||
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
||||||
let d = projection.to_direction(scale, u, v);
|
let d = projection.to_direction(scale, u, v);
|
||||||
let mut sum = [0.0f32; 3];
|
let mut sum = [0.0f32; 3];
|
||||||
let mut n = 0.0f32;
|
let mut n = 0u32;
|
||||||
for (k, g) in proxies.iter().enumerate() {
|
for (k, g) in proxies.iter().enumerate() {
|
||||||
let Some((x, y)) = cameras.project(k, d) else {
|
let Some((x, y)) = cameras.project(k, d) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -1334,21 +1276,16 @@ fn preview_planes(
|
|||||||
let i = (y as usize * g.width + x as usize) * 3;
|
let i = (y as usize * g.width + x as usize) * 3;
|
||||||
let cam =
|
let cam =
|
||||||
grey_if_blown([colour[k][i], colour[k][i + 1], colour[k][i + 2]], look.wb);
|
grey_if_blown([colour[k][i], colour[k][i + 1], colour[k][i + 2]], look.wb);
|
||||||
// The seam's share, as the merge weighs it, with the same
|
|
||||||
// floor under it; an even average where there is no map.
|
|
||||||
let w = seams
|
|
||||||
.and_then(|(m, radius)| m.share(k, u, v, scale, radius))
|
|
||||||
.map_or(1.0, |s| s + 1e-4);
|
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
sum[c] += cam[c] * gains[k] * w;
|
sum[c] += cam[c] * gains[k];
|
||||||
}
|
}
|
||||||
n += w;
|
n += 1;
|
||||||
}
|
}
|
||||||
if n <= 0.0 {
|
if n == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let o = oy * out_w + ox;
|
let o = oy * out_w + ox;
|
||||||
let d = look.to_display([sum[0] / n, sum[1] / n, sum[2] / n]);
|
let d = look.to_display([sum[0] / n as f32, sum[1] / n as f32, sum[2] / n as f32]);
|
||||||
rgb[o * 3..o * 3 + 3].copy_from_slice(&d);
|
rgb[o * 3..o * 3 + 3].copy_from_slice(&d);
|
||||||
known[o] = true;
|
known[o] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -740,9 +740,6 @@ fn metadata(
|
|||||||
[target.image.0 as i64],
|
[target.image.0 as i64],
|
||||||
)
|
)
|
||||||
.map_err(|e| Failure::Other(e.to_string()))?;
|
.map_err(|e| Failure::Other(e.to_string()))?;
|
||||||
// The name, when the header had no date — as the sweep does.
|
|
||||||
dr_catalog::name_dates::fill(catalog.connection(), Some(&[target.image.0 as i64]))
|
|
||||||
.map_err(|e| Failure::Other(e.to_string()))?;
|
|
||||||
}
|
}
|
||||||
Ok(1)
|
Ok(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -26,7 +26,7 @@ use jni::strings::JNIStr;
|
|||||||
/// Run `body` with the application context ndk_context holds, on whatever
|
/// Run `body` with the application context ndk_context holds, on whatever
|
||||||
/// thread this is. It is a `Context`, not the activity — see
|
/// thread this is. It is a `Context`, not the activity — see
|
||||||
/// `FolderPicker.start` for what that changes.
|
/// `FolderPicker.start` for what that changes.
|
||||||
pub(crate) fn call<T>(
|
fn call<T>(
|
||||||
what: &str,
|
what: &str,
|
||||||
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
||||||
) -> Result<T, String> {
|
) -> Result<T, String> {
|
||||||
@@ -62,7 +62,7 @@ pub(crate) fn call<T>(
|
|||||||
/// or the application context behind it) the boot loader defined, and the
|
/// or the application context behind it) the boot loader defined, and the
|
||||||
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
|
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
|
||||||
/// which is what the first build on the tablet did.
|
/// which is what the first build on the tablet did.
|
||||||
pub(crate) fn class<'local>(
|
fn class<'local>(
|
||||||
env: &mut jni::Env<'local>,
|
env: &mut jni::Env<'local>,
|
||||||
activity: &JObject,
|
activity: &JObject,
|
||||||
name: &JNIStr,
|
name: &JNIStr,
|
||||||
@@ -80,7 +80,7 @@ pub(crate) fn class<'local>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A Java string result, or `None` for null.
|
/// A Java string result, or `None` for null.
|
||||||
pub(crate) fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
||||||
if value.is_null() {
|
if value.is_null() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
// 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");
|
|
||||||
}
|
|
||||||
@@ -569,7 +569,6 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> import-volume-selected: -1;
|
in property <int> import-volume-selected: -1;
|
||||||
in property <string> import-card-path: "";
|
in property <string> import-card-path: "";
|
||||||
in property <bool> import-card-looks-right: false;
|
in property <bool> import-card-looks-right: false;
|
||||||
in property <bool> import-needs-access: false;
|
|
||||||
in property <bool> import-surveying: false;
|
in property <bool> import-surveying: false;
|
||||||
in property <string> import-survey-summary: "";
|
in property <string> import-survey-summary: "";
|
||||||
in property <string> import-upload-target: "";
|
in property <string> import-upload-target: "";
|
||||||
@@ -631,7 +630,6 @@ export component AppWindow inherits Window {
|
|||||||
callback import-card-path-changed(string);
|
callback import-card-path-changed(string);
|
||||||
callback import-choose-card();
|
callback import-choose-card();
|
||||||
callback import-refresh-volumes();
|
callback import-refresh-volumes();
|
||||||
callback import-grant-access();
|
|
||||||
callback import-template-changed(string);
|
callback import-template-changed(string);
|
||||||
callback import-mode-picked(int);
|
callback import-mode-picked(int);
|
||||||
callback import-duplicate-picked(int);
|
callback import-duplicate-picked(int);
|
||||||
@@ -1124,7 +1122,6 @@ in property <bool> panel-visible: true;
|
|||||||
volume-selected: root.import-volume-selected;
|
volume-selected: root.import-volume-selected;
|
||||||
card-path: root.import-card-path;
|
card-path: root.import-card-path;
|
||||||
card-looks-right: root.import-card-looks-right;
|
card-looks-right: root.import-card-looks-right;
|
||||||
needs-access: root.import-needs-access;
|
|
||||||
surveying: root.import-surveying;
|
surveying: root.import-surveying;
|
||||||
survey-summary: root.import-survey-summary;
|
survey-summary: root.import-survey-summary;
|
||||||
upload-target: root.import-upload-target;
|
upload-target: root.import-upload-target;
|
||||||
@@ -1148,7 +1145,6 @@ in property <bool> panel-visible: true;
|
|||||||
card-path-changed(t) => { root.import-card-path-changed(t); }
|
card-path-changed(t) => { root.import-card-path-changed(t); }
|
||||||
choose-card => { root.import-choose-card(); }
|
choose-card => { root.import-choose-card(); }
|
||||||
refresh-volumes() => { root.import-refresh-volumes(); }
|
refresh-volumes() => { root.import-refresh-volumes(); }
|
||||||
grant-access() => { root.import-grant-access(); }
|
|
||||||
template-changed(t) => { root.import-template-changed(t); }
|
template-changed(t) => { root.import-template-changed(t); }
|
||||||
mode-picked(i) => { root.import-mode-picked(i); }
|
mode-picked(i) => { root.import-mode-picked(i); }
|
||||||
duplicate-picked(i) => { root.import-duplicate-picked(i); }
|
duplicate-picked(i) => { root.import-duplicate-picked(i); }
|
||||||
|
|||||||
@@ -75,17 +75,12 @@ export component ImportPage inherits Rectangle {
|
|||||||
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
||||||
/// sources do not, so this informs and never blocks.
|
/// sources do not, so this informs and never blocks.
|
||||||
in property <bool> card-looks-right: false;
|
in property <bool> card-looks-right: false;
|
||||||
/// Android: the app may not read a card until the user allows "all files
|
|
||||||
/// access", which is a switch in the system settings, not a dialog.
|
|
||||||
in property <bool> needs-access: false;
|
|
||||||
|
|
||||||
callback volume-picked(int);
|
callback volume-picked(int);
|
||||||
callback card-path-changed(string);
|
callback card-path-changed(string);
|
||||||
/// Browse for the source with the platform's dialogue.
|
/// Browse for the source with the platform's dialogue.
|
||||||
callback choose-card();
|
callback choose-card();
|
||||||
callback refresh-volumes();
|
callback refresh-volumes();
|
||||||
/// Open the system page where that switch is.
|
|
||||||
callback grant-access();
|
|
||||||
|
|
||||||
// --- what is on it ---------------------------------------------------
|
// --- what is on it ---------------------------------------------------
|
||||||
in property <bool> surveying: false;
|
in property <bool> surveying: false;
|
||||||
@@ -227,27 +222,10 @@ export component ImportPage inherits Rectangle {
|
|||||||
Panel {
|
Panel {
|
||||||
PanelHeading { text: "From"; }
|
PanelHeading { text: "From"; }
|
||||||
|
|
||||||
// Android, before the grant. Said once, with the one
|
if root.volume-labels.length == 0: Caption {
|
||||||
// control that fixes it, instead of an empty list that
|
|
||||||
// would read as "no card inserted".
|
|
||||||
if root.needs-access: Caption {
|
|
||||||
text: "To read a camera card, DarkRoom needs \"All files access\". Allow it on the next screen, then come back here.";
|
|
||||||
wrap: word-wrap;
|
|
||||||
}
|
|
||||||
if root.needs-access: HorizontalLayout {
|
|
||||||
Button {
|
|
||||||
text: "Allow access";
|
|
||||||
primary: true;
|
|
||||||
enabled: !root.running;
|
|
||||||
clicked => { root.grant-access(); }
|
|
||||||
}
|
|
||||||
Rectangle { horizontal-stretch: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if !root.needs-access && root.volume-labels.length == 0: Caption {
|
|
||||||
text: Pickers.local-paths
|
text: Pickers.local-paths
|
||||||
? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh."
|
? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh."
|
||||||
: "No SD card or card reader found. Insert one and press Refresh.";
|
: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import { Icon } from "icons.slint";
|
|||||||
import { ViewMode } from "session.slint";
|
import { ViewMode } from "session.slint";
|
||||||
import { PresetRow, PresetOrigin, PresetFolderRow } from "presets.slint";
|
import { PresetRow, PresetOrigin, PresetFolderRow } from "presets.slint";
|
||||||
import { Label, ScrollBar } from "widgets.slint";
|
import { Label, ScrollBar } from "widgets.slint";
|
||||||
import { Keys } from "keys.slint";
|
|
||||||
|
|
||||||
// One tool. A struct rather than four parallel arrays so a row cannot be
|
// One tool. A struct rather than four parallel arrays so a row cannot be
|
||||||
// half-added — the compiler will not let a new entry omit its icon.
|
// half-added — the compiler will not let a new entry omit its icon.
|
||||||
@@ -523,30 +522,6 @@ export component ToolRail inherits Rectangle {
|
|||||||
root.height - Theme.gap);
|
root.height - Theme.gap);
|
||||||
close-policy: PopupClosePolicy.close-on-click-outside;
|
close-policy: PopupClosePolicy.close-on-click-outside;
|
||||||
|
|
||||||
// Somewhere for Back to start from while the menu is up.
|
|
||||||
//
|
|
||||||
// A popup is its own focus tree: showing it takes focus off the
|
|
||||||
// develop view, and Slint hands it back only when the popup
|
|
||||||
// closes. With nothing focusable in here, Android's Back gesture
|
|
||||||
// — the natural way to dismiss a menu — found no focus item,
|
|
||||||
// went unanswered, and the platform closed the application.
|
|
||||||
// Slint closes a popup on Escape by itself but knows nothing of
|
|
||||||
// Back, so both are answered here, as the film list does.
|
|
||||||
FocusScope {
|
|
||||||
width: 0px;
|
|
||||||
height: 0px;
|
|
||||||
init => { self.focus(); }
|
|
||||||
|
|
||||||
// KEYMAP: Develop
|
|
||||||
key-pressed(event) => {
|
|
||||||
if (Keys.chord(event) == "Escape" || Keys.chord(event) == "Back") {
|
|
||||||
preset-menu.close();
|
|
||||||
return accept;
|
|
||||||
}
|
|
||||||
return reject;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
|
|||||||
Reference in New Issue
Block a user